Initial commit: DataTool backend, frontend and Docker
This commit is contained in:
13
frontend/index.html
Normal file
13
frontend/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>DataTool - 房间数据传输助手</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
</head>
|
||||
<body class="bg-slate-50">
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
2805
frontend/package-lock.json
generated
Normal file
2805
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
28
frontend/package.json
Normal file
28
frontend/package.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "datatool-frontend",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.7.0",
|
||||
"pinia": "^2.1.7",
|
||||
"vue": "^3.4.0",
|
||||
"vue-router": "^4.3.0",
|
||||
"sockjs-client": "^1.6.1",
|
||||
"@stomp/stompjs": "^7.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.10.0",
|
||||
"@vitejs/plugin-vue": "^5.0.0",
|
||||
"autoprefixer": "^10.4.0",
|
||||
"postcss": "^8.4.0",
|
||||
"tailwindcss": "^3.4.0",
|
||||
"typescript": "^5.4.0",
|
||||
"vite": "^5.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
7
frontend/postcss.config.cjs
Normal file
7
frontend/postcss.config.cjs
Normal file
@@ -0,0 +1,7 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
|
||||
60
frontend/src/App.vue
Normal file
60
frontend/src/App.vue
Normal file
@@ -0,0 +1,60 @@
|
||||
<template>
|
||||
<div class="min-h-screen flex flex-col">
|
||||
<header
|
||||
class="h-14 flex items-center justify-between px-6 border-b border-slate-200 bg-white/80 backdrop-blur"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-base font-semibold text-slate-900">DataTool</span>
|
||||
<span class="text-xs text-slate-500 hidden sm:inline">
|
||||
轻量级房间数据传输助手
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 text-xs text-slate-600">
|
||||
<StatusDot :status="statusDot">
|
||||
{{ statusLabel }}
|
||||
</StatusDot>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="flex-1">
|
||||
<router-view />
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import StatusDot from '@/components/ui/StatusDot.vue';
|
||||
import { useWsStore } from '@/stores/wsStore';
|
||||
|
||||
const wsStore = useWsStore();
|
||||
|
||||
const statusDot = computed(() => {
|
||||
switch (wsStore.status) {
|
||||
case 'connecting':
|
||||
return 'warning';
|
||||
case 'connected':
|
||||
return 'online';
|
||||
case 'reconnecting':
|
||||
return 'reconnecting';
|
||||
case 'disconnected':
|
||||
default:
|
||||
return 'error';
|
||||
}
|
||||
});
|
||||
|
||||
const statusLabel = computed(() => {
|
||||
switch (wsStore.status) {
|
||||
case 'connecting':
|
||||
return '连接中…';
|
||||
case 'connected':
|
||||
return '已连接';
|
||||
case 'reconnecting':
|
||||
return '重连中…';
|
||||
case 'disconnected':
|
||||
default:
|
||||
return '已断开';
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
111
frontend/src/api/room.ts
Normal file
111
frontend/src/api/room.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* 房间相关 REST API:文件上传与下载 URL。
|
||||
* 大文件走 HTTP 上传/下载,避免 WebSocket 长传断连。
|
||||
*/
|
||||
|
||||
export interface UploadFileResponse {
|
||||
fileId: string;
|
||||
fileName: string;
|
||||
fileSize: number;
|
||||
mimeType: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传文件到房间,落盘到服务器,返回文件元数据。
|
||||
* onProgress(0~100):上传进度;当 lengthComputable 为 false 时用 file.size 估算。
|
||||
*/
|
||||
export async function uploadRoomFile(
|
||||
roomCode: string,
|
||||
file: File,
|
||||
onProgress?: (percent: number) => void,
|
||||
): Promise<UploadFileResponse> {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
const xhr = new XMLHttpRequest();
|
||||
const url = `/api/room/${encodeURIComponent(roomCode)}/file/upload`;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
xhr.upload.addEventListener('progress', (e) => {
|
||||
if (!onProgress) return;
|
||||
if (e.lengthComputable) {
|
||||
onProgress(Math.round((e.loaded / e.total) * 100));
|
||||
} else if (e.loaded > 0 && file.size > 0) {
|
||||
onProgress(Math.min(99, Math.round((e.loaded / file.size) * 100)));
|
||||
}
|
||||
});
|
||||
xhr.addEventListener('load', () => {
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
try {
|
||||
const data = JSON.parse(xhr.responseText) as UploadFileResponse;
|
||||
resolve(data);
|
||||
} catch {
|
||||
reject(new Error('解析响应失败'));
|
||||
}
|
||||
} else {
|
||||
let message = `上传失败 ${xhr.status}`;
|
||||
try {
|
||||
const body = JSON.parse(xhr.responseText);
|
||||
if (body.message) message = body.message;
|
||||
} catch {
|
||||
if (xhr.responseText) message = xhr.responseText;
|
||||
}
|
||||
reject(new Error(message));
|
||||
}
|
||||
});
|
||||
xhr.addEventListener('error', () => reject(new Error('网络错误')));
|
||||
xhr.addEventListener('abort', () => reject(new Error('上传已取消')));
|
||||
|
||||
xhr.open('POST', url);
|
||||
xhr.send(formData);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前客户端的 IP(由服务端从请求中解析),用于作为默认昵称。
|
||||
*/
|
||||
export async function getMyIp(apiBase = ''): Promise<string> {
|
||||
const url = `${apiBase}/api/room/my-ip`.replace(/\/+/g, '/');
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) throw new Error(`获取 IP 失败: ${res.status}`);
|
||||
const data = (await res.json()) as { ip?: string };
|
||||
return data.ip ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回房间内文件的下载 URL(相对路径,走当前 origin 的 /api 代理)。
|
||||
*/
|
||||
export function getFileDownloadUrl(roomCode: string, fileId: string): string {
|
||||
return `/api/room/${encodeURIComponent(roomCode)}/file/${encodeURIComponent(fileId)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 带进度的 HTTP 下载,返回 Blob。onProgress(0~100) 在 lengthComputable 时有效。
|
||||
*/
|
||||
export function downloadWithProgress(
|
||||
url: string,
|
||||
onProgress?: (percent: number) => void,
|
||||
): Promise<Blob> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.responseType = 'blob';
|
||||
|
||||
xhr.addEventListener('progress', (e) => {
|
||||
if (onProgress && e.lengthComputable && e.total > 0) {
|
||||
onProgress(Math.round((e.loaded / e.total) * 100));
|
||||
}
|
||||
});
|
||||
xhr.addEventListener('load', () => {
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
resolve(xhr.response as Blob);
|
||||
} else {
|
||||
reject(new Error(xhr.status === 404 ? '文件不存在' : `下载失败 ${xhr.status}`));
|
||||
}
|
||||
});
|
||||
xhr.addEventListener('error', () => reject(new Error('网络错误')));
|
||||
xhr.addEventListener('abort', () => reject(new Error('已取消')));
|
||||
|
||||
xhr.open('GET', url);
|
||||
xhr.send();
|
||||
});
|
||||
}
|
||||
149
frontend/src/api/websocket.ts
Normal file
149
frontend/src/api/websocket.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* WebSocket 连接管理封装(SockJS + STOMP)
|
||||
* 文档:03-WebSocket连接管理(连接-订阅-心跳-重连)
|
||||
*
|
||||
* - connect:创建 STOMP Client,启用 reconnectDelay 与心跳
|
||||
* - subscribe:按 destination 保存订阅,便于统一退订
|
||||
* - send:发送 JSON 消息
|
||||
* - disconnect:取消订阅并关闭连接
|
||||
*/
|
||||
|
||||
import { Client, type IMessage, type StompSubscription } from '@stomp/stompjs';
|
||||
import SockJS from 'sockjs-client';
|
||||
|
||||
export type ConnectionStatus =
|
||||
| 'connecting'
|
||||
| 'connected'
|
||||
| 'reconnecting'
|
||||
| 'disconnected';
|
||||
|
||||
export type OnConnect = () => void;
|
||||
export type OnError = (err: unknown) => void;
|
||||
export type OnStatusChange = (status: ConnectionStatus) => void;
|
||||
|
||||
const DEFAULT_RECONNECT_DELAY_MS = 2000;
|
||||
const DEFAULT_HEARTBEAT_IN_MS = 10000;
|
||||
const DEFAULT_HEARTBEAT_OUT_MS = 10000;
|
||||
|
||||
export interface WebSocketApi {
|
||||
connect(
|
||||
url: string,
|
||||
onConnect: OnConnect,
|
||||
onError?: OnError,
|
||||
onStatusChange?: OnStatusChange,
|
||||
): void;
|
||||
subscribe(destination: string, callback: (message: IMessage) => void): StompSubscription | null;
|
||||
send(destination: string, body: object | string): void;
|
||||
disconnect(): void;
|
||||
getStatus(): ConnectionStatus;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建 WebSocket 封装实例,具备心跳保活与自动重连。
|
||||
* 连接时序建议:connect 成功 → subscribe /topic/... → send /app/... → 收发消息。
|
||||
*/
|
||||
export function createWebSocketClient(): WebSocketApi {
|
||||
let client: Client | null = null;
|
||||
const subscriptions: StompSubscription[] = [];
|
||||
let status: ConnectionStatus = 'disconnected';
|
||||
let onStatusChangeCb: OnStatusChange | undefined;
|
||||
|
||||
function setStatus(s: ConnectionStatus) {
|
||||
status = s;
|
||||
onStatusChangeCb?.(s);
|
||||
}
|
||||
|
||||
function connect(
|
||||
url: string,
|
||||
onConnect: OnConnect,
|
||||
onError?: OnError,
|
||||
onStatusChange?: OnStatusChange,
|
||||
): void {
|
||||
onStatusChangeCb = onStatusChange;
|
||||
if (client && status === 'connected') return;
|
||||
|
||||
subscriptions.forEach((s) => {
|
||||
try {
|
||||
s.unsubscribe();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
});
|
||||
subscriptions.length = 0;
|
||||
|
||||
setStatus('connecting');
|
||||
|
||||
client = new Client({
|
||||
webSocketFactory: () => new SockJS(url) as unknown as WebSocket,
|
||||
reconnectDelay: DEFAULT_RECONNECT_DELAY_MS,
|
||||
heartbeatIncoming: DEFAULT_HEARTBEAT_IN_MS,
|
||||
heartbeatOutgoing: DEFAULT_HEARTBEAT_OUT_MS,
|
||||
// 大消息分片:64KB base64 分片约 87KB,需拆分发送;与 Spring 后端 1MB 限制配合
|
||||
splitLargeFrames: true,
|
||||
maxWebSocketChunkSize: 64 * 1024,
|
||||
onConnect: () => {
|
||||
// 重连后旧订阅已失效,清空列表由调用方重新 subscribe + join
|
||||
subscriptions.length = 0;
|
||||
setStatus('connected');
|
||||
onConnect();
|
||||
},
|
||||
onStompError: (frame) => {
|
||||
setStatus('disconnected');
|
||||
onError?.(frame);
|
||||
},
|
||||
onWebSocketClose: () => {
|
||||
if (client?.active) {
|
||||
setStatus('reconnecting');
|
||||
} else {
|
||||
setStatus('disconnected');
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
client.activate();
|
||||
}
|
||||
|
||||
function subscribe(
|
||||
destination: string,
|
||||
callback: (message: IMessage) => void,
|
||||
): StompSubscription | null {
|
||||
if (!client?.connected) return null;
|
||||
const sub = client.subscribe(destination, callback);
|
||||
subscriptions.push(sub);
|
||||
return sub;
|
||||
}
|
||||
|
||||
function send(destination: string, body: object | string): void {
|
||||
if (!client?.connected) return;
|
||||
const bodyStr = typeof body === 'string' ? body : JSON.stringify(body);
|
||||
client.publish({ destination, body: bodyStr });
|
||||
}
|
||||
|
||||
function disconnect(): void {
|
||||
subscriptions.forEach((s) => {
|
||||
try {
|
||||
s.unsubscribe();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
});
|
||||
subscriptions.length = 0;
|
||||
if (client) {
|
||||
client.deactivate();
|
||||
client = null;
|
||||
}
|
||||
setStatus('disconnected');
|
||||
}
|
||||
|
||||
function getStatus(): ConnectionStatus {
|
||||
return status;
|
||||
}
|
||||
|
||||
return {
|
||||
connect,
|
||||
subscribe,
|
||||
send,
|
||||
disconnect,
|
||||
getStatus,
|
||||
};
|
||||
}
|
||||
35
frontend/src/assets/main.css
Normal file
35
frontend/src/assets/main.css
Normal file
@@ -0,0 +1,35 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
--dt-radius-btn: 4px;
|
||||
--dt-radius-card: 8px;
|
||||
--dt-radius-modal: 12px;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply text-slate-900 bg-slate-50;
|
||||
font-family: system-ui, -apple-system, BlinkMacSystemFont, "SF Pro SC",
|
||||
"PingFang SC", "Microsoft YaHei", sans-serif;
|
||||
}
|
||||
|
||||
.dt-input {
|
||||
@apply w-full h-10 px-3 rounded-[var(--dt-radius-btn)] border border-slate-200 bg-white text-sm text-slate-900
|
||||
placeholder:text-slate-400 outline-none focus:border-primary focus:ring-1 focus:ring-primary transition;
|
||||
}
|
||||
|
||||
.dt-textarea {
|
||||
@apply w-full px-3 py-2 rounded-[var(--dt-radius-card)] border border-slate-200 bg-white text-sm text-slate-900
|
||||
placeholder:text-slate-400 outline-none focus:border-primary focus:ring-1 focus:ring-primary transition resize-y;
|
||||
}
|
||||
|
||||
/* Toast 淡入淡出(文档 15:约 150ms) */
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
215
frontend/src/components/FileDropZone.vue
Normal file
215
frontend/src/components/FileDropZone.vue
Normal file
@@ -0,0 +1,215 @@
|
||||
<template>
|
||||
<div class="space-y-2">
|
||||
<!-- 拖拽/点击上传区域 -->
|
||||
<div
|
||||
ref="zoneRef"
|
||||
class="rounded-[var(--dt-radius-card)] border border-dashed border-slate-300 bg-slate-50 px-3 py-2 text-center transition-colors"
|
||||
:class="{ 'border-primary bg-blue-50/50': isDragOver }"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
aria-label="拖拽或粘贴文件到此处上传"
|
||||
@click="triggerInput"
|
||||
@keydown.enter.prevent="triggerInput"
|
||||
@keydown.space.prevent="triggerInput"
|
||||
@dragover.prevent="isDragOver = true"
|
||||
@dragleave.prevent="isDragOver = false"
|
||||
@drop.prevent="handleDrop"
|
||||
>
|
||||
<input
|
||||
ref="inputRef"
|
||||
type="file"
|
||||
class="sr-only"
|
||||
multiple
|
||||
@change="handleInputChange"
|
||||
/>
|
||||
<span class="inline-flex items-center gap-1.5 text-[11px] text-slate-500">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-4 w-4 shrink-0 text-slate-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
|
||||
/>
|
||||
</svg>
|
||||
拖拽文件到此处,或点击 / Ctrl+V 粘贴
|
||||
<span class="text-slate-400">· 单文件最大 50MB</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 读取剪贴板按钮 -->
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-1.5 rounded-md border border-slate-200 bg-white px-2.5 py-1.5 text-[11px] text-slate-600 transition-colors hover:bg-slate-50 hover:border-slate-300 focus:outline-none focus:ring-2 focus:ring-primary/20 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
:disabled="isReadingClipboard"
|
||||
:title="clipboardUnavailableReason ?? '读取系统剪贴板文本'"
|
||||
@click="handleReadClipboard"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-3.5 w-3.5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"
|
||||
/>
|
||||
</svg>
|
||||
{{ isReadingClipboard ? '读取中...' : '读取剪贴板' }}
|
||||
</button>
|
||||
<span v-if="clipboardMessage" class="text-[11px]" :class="clipboardMessageClass">
|
||||
{{ clipboardMessage }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue';
|
||||
import {
|
||||
readClipboardText,
|
||||
extractFilesFromPaste,
|
||||
extractTextFromPaste,
|
||||
pasteHasFiles,
|
||||
getClipboardUnavailableReason,
|
||||
} from '@/utils/clipboard';
|
||||
|
||||
const emit = defineEmits<{
|
||||
fileSelected: [files: File[]];
|
||||
textPasted: [text: string];
|
||||
}>();
|
||||
|
||||
const zoneRef = ref<HTMLElement | null>(null);
|
||||
const inputRef = ref<HTMLInputElement | null>(null);
|
||||
const isDragOver = ref(false);
|
||||
|
||||
// 剪贴板读取状态
|
||||
const isReadingClipboard = ref(false);
|
||||
const clipboardMessage = ref('');
|
||||
const clipboardMessageType = ref<'success' | 'error' | 'info'>('info');
|
||||
let clipboardMessageTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
// 剪贴板不可用原因(用于 title 提示)
|
||||
const clipboardUnavailableReason = computed(() => getClipboardUnavailableReason());
|
||||
|
||||
// 消息样式
|
||||
const clipboardMessageClass = computed(() => {
|
||||
switch (clipboardMessageType.value) {
|
||||
case 'success':
|
||||
return 'text-green-600';
|
||||
case 'error':
|
||||
return 'text-red-500';
|
||||
default:
|
||||
return 'text-slate-500';
|
||||
}
|
||||
});
|
||||
|
||||
function showClipboardMessage(message: string, type: 'success' | 'error' | 'info' = 'info') {
|
||||
clipboardMessage.value = message;
|
||||
clipboardMessageType.value = type;
|
||||
if (clipboardMessageTimer) clearTimeout(clipboardMessageTimer);
|
||||
clipboardMessageTimer = setTimeout(() => {
|
||||
clipboardMessage.value = '';
|
||||
clipboardMessageTimer = null;
|
||||
}, 4000);
|
||||
}
|
||||
|
||||
function triggerInput() {
|
||||
inputRef.value?.click();
|
||||
}
|
||||
|
||||
function emitFiles(files: FileList | File[]) {
|
||||
const list = Array.from(files).filter((f) => f && f.size >= 0);
|
||||
if (list.length) emit('fileSelected', list);
|
||||
}
|
||||
|
||||
function handleDrop(e: DragEvent) {
|
||||
isDragOver.value = false;
|
||||
const files = e.dataTransfer?.files;
|
||||
if (files?.length) emitFiles(files);
|
||||
}
|
||||
|
||||
function handleInputChange(e: Event) {
|
||||
const input = e.target as HTMLInputElement;
|
||||
if (input.files?.length) emitFiles(input.files);
|
||||
input.value = '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理粘贴事件
|
||||
* - 优先处理文件(如截图、复制的文件)
|
||||
* - 无文件时处理文本
|
||||
*/
|
||||
function handlePaste(e: ClipboardEvent) {
|
||||
// 如果焦点在输入框内,不处理(让输入框自行处理)
|
||||
const activeEl = document.activeElement;
|
||||
if (
|
||||
activeEl instanceof HTMLTextAreaElement ||
|
||||
activeEl instanceof HTMLInputElement ||
|
||||
(activeEl instanceof HTMLElement && activeEl.isContentEditable)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 优先处理文件
|
||||
if (pasteHasFiles(e)) {
|
||||
const files = extractFilesFromPaste(e);
|
||||
if (files.length) {
|
||||
e.preventDefault();
|
||||
emitFiles(files);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 处理文本
|
||||
const text = extractTextFromPaste(e);
|
||||
if (text && text.trim()) {
|
||||
e.preventDefault();
|
||||
emit('textPasted', text);
|
||||
showClipboardMessage('已粘贴文本内容', 'success');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 主动读取剪贴板(需要用户点击触发)
|
||||
*/
|
||||
async function handleReadClipboard() {
|
||||
if (isReadingClipboard.value) return;
|
||||
|
||||
isReadingClipboard.value = true;
|
||||
clipboardMessage.value = '';
|
||||
|
||||
try {
|
||||
const result = await readClipboardText();
|
||||
if (result.success && result.text) {
|
||||
emit('textPasted', result.text);
|
||||
showClipboardMessage('已读取剪贴板文本', 'success');
|
||||
} else {
|
||||
showClipboardMessage(result.error ?? '读取失败', 'error');
|
||||
}
|
||||
} finally {
|
||||
isReadingClipboard.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('paste', handlePaste);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('paste', handlePaste);
|
||||
if (clipboardMessageTimer) clearTimeout(clipboardMessageTimer);
|
||||
});
|
||||
</script>
|
||||
149
frontend/src/components/FileMessage.vue
Normal file
149
frontend/src/components/FileMessage.vue
Normal file
@@ -0,0 +1,149 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex w-full gap-3 items-end"
|
||||
:class="isMe ? 'flex-row-reverse' : ''"
|
||||
role="article"
|
||||
>
|
||||
<span
|
||||
class="inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-full text-xs font-medium text-white shadow-sm"
|
||||
:class="avatarColorClass"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{{ avatarLetter }}
|
||||
</span>
|
||||
<div
|
||||
class="max-w-[82%] rounded-2xl px-4 py-2.5 shadow-sm transition-shadow duration-200"
|
||||
:class="
|
||||
isMe
|
||||
? 'bg-primary text-white rounded-br-md'
|
||||
: 'bg-white text-slate-900 border border-slate-200/80 rounded-bl-md'
|
||||
"
|
||||
>
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<span
|
||||
class="text-xs font-medium min-w-0 truncate"
|
||||
:class="isMe ? 'text-white/90' : 'text-slate-600'"
|
||||
>
|
||||
{{ shortDisplayName }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded"
|
||||
:class="isMe ? 'bg-white/20 text-white' : 'bg-slate-100 text-slate-500'"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
</span>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p
|
||||
class="truncate font-medium"
|
||||
:class="isMe ? 'text-white' : 'text-slate-900'"
|
||||
:title="fileName"
|
||||
>
|
||||
{{ displayFileName }}
|
||||
</p>
|
||||
<p class="text-xs" :class="isMe ? 'text-white/80' : 'text-slate-500'">
|
||||
{{ formatSize(fileSize) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="showProgress" class="mt-2">
|
||||
<div
|
||||
class="h-1.5 w-full overflow-hidden rounded-full bg-slate-200"
|
||||
role="progressbar"
|
||||
:aria-valuenow="progress"
|
||||
aria-valuemin="0"
|
||||
aria-valuemax="100"
|
||||
>
|
||||
<div
|
||||
class="h-full rounded-full transition-all duration-200"
|
||||
:class="progressBarClass"
|
||||
:style="{ width: `${Math.min(100, progress)}%` }"
|
||||
/>
|
||||
</div>
|
||||
<p class="mt-1 text-xs" :class="isMe ? 'text-white/80' : 'text-slate-500'">
|
||||
{{ statusLabel }}
|
||||
</p>
|
||||
</div>
|
||||
<div v-else-if="status === 'done'" class="mt-2 flex items-center gap-2">
|
||||
<BaseButton size="sm" variant="secondary" @click="$emit('download')">
|
||||
下载
|
||||
</BaseButton>
|
||||
</div>
|
||||
<div v-else-if="status === 'error'" class="mt-2">
|
||||
<p class="text-xs text-danger">传输失败</p>
|
||||
<BaseButton size="sm" variant="ghost" class="mt-1" @click="$emit('retry')">
|
||||
重试
|
||||
</BaseButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import BaseButton from '@/components/ui/BaseButton.vue';
|
||||
import { getAvatarLetter, getAvatarColorClass, getShortDisplayName } from '@/utils/avatar';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
fileName: string;
|
||||
fileSize?: number;
|
||||
senderName?: string;
|
||||
isMe?: boolean;
|
||||
status?: 'sending' | 'receiving' | 'done' | 'error';
|
||||
progress?: number; // 0–100
|
||||
}>(),
|
||||
{
|
||||
fileSize: 0,
|
||||
isMe: false,
|
||||
status: 'done',
|
||||
progress: 0,
|
||||
},
|
||||
);
|
||||
|
||||
defineEmits<{
|
||||
download: [];
|
||||
retry: [];
|
||||
}>();
|
||||
|
||||
const displayName = computed(() => {
|
||||
if (props.senderName) return props.senderName;
|
||||
return props.isMe ? '我' : '未知';
|
||||
});
|
||||
|
||||
const shortDisplayName = computed(() => getShortDisplayName(displayName.value));
|
||||
const avatarLetter = computed(() => getAvatarLetter(displayName.value));
|
||||
const avatarColorClass = computed(() => getAvatarColorClass(displayName.value));
|
||||
|
||||
const displayFileName = computed(() => {
|
||||
const name = props.fileName || '未命名文件';
|
||||
if (name.length <= 24) return name;
|
||||
return name.slice(0, 10) + '…' + name.slice(-10);
|
||||
});
|
||||
|
||||
const showProgress = computed(
|
||||
() => props.status === 'sending' || props.status === 'receiving',
|
||||
);
|
||||
|
||||
const statusLabel = computed(() => {
|
||||
if (props.status === 'sending') return `发送中 ${props.progress}%`;
|
||||
if (props.status === 'receiving') return `接收中 ${props.progress}%`;
|
||||
return '';
|
||||
});
|
||||
|
||||
const progressBarClass = computed(() => {
|
||||
if (props.status === 'error') return 'bg-danger';
|
||||
return 'bg-primary';
|
||||
});
|
||||
|
||||
function formatSize(bytes: number): string {
|
||||
if (bytes <= 0) return '—';
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
</script>
|
||||
278
frontend/src/components/ImageMessage.vue
Normal file
278
frontend/src/components/ImageMessage.vue
Normal file
@@ -0,0 +1,278 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex w-full gap-3 items-end"
|
||||
:class="isMe ? 'flex-row-reverse' : ''"
|
||||
role="article"
|
||||
>
|
||||
<span
|
||||
class="inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-full text-xs font-medium text-white shadow-sm"
|
||||
:class="avatarColorClass"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{{ avatarLetter }}
|
||||
</span>
|
||||
<div
|
||||
class="max-w-[82%] rounded-2xl px-4 py-2.5 shadow-sm transition-shadow duration-200"
|
||||
:class="
|
||||
isMe
|
||||
? 'bg-primary text-white rounded-br-md'
|
||||
: 'bg-white text-slate-900 border border-slate-200/80 rounded-bl-md'
|
||||
"
|
||||
>
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<span
|
||||
class="text-xs font-medium min-w-0 truncate"
|
||||
:class="isMe ? 'text-white/90' : 'text-slate-600'"
|
||||
>
|
||||
{{ shortDisplayName }}
|
||||
</span>
|
||||
<span
|
||||
v-if="timestamp"
|
||||
class="text-[11px] ml-auto shrink-0 tabular-nums"
|
||||
:class="isMe ? 'text-white/70' : 'text-slate-400'"
|
||||
>
|
||||
{{ formatTime(timestamp) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 图片预览区域 -->
|
||||
<div class="relative">
|
||||
<!-- 加载中状态 -->
|
||||
<div
|
||||
v-if="status === 'receiving' || status === 'sending'"
|
||||
class="flex flex-col items-center justify-center rounded-lg bg-slate-100 p-4"
|
||||
:style="{ minHeight: '120px', maxWidth: '280px' }"
|
||||
>
|
||||
<div class="h-8 w-8 animate-spin rounded-full border-2 border-slate-300 border-t-primary" />
|
||||
<p class="mt-2 text-xs text-slate-500">
|
||||
{{ status === 'sending' ? '发送中' : '接收中' }} {{ progress }}%
|
||||
</p>
|
||||
<div class="mt-2 h-1.5 w-full max-w-[160px] overflow-hidden rounded-full bg-slate-200">
|
||||
<div
|
||||
class="h-full rounded-full bg-primary transition-all duration-200"
|
||||
:style="{ width: `${Math.min(100, progress)}%` }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 图片显示 -->
|
||||
<div
|
||||
v-else-if="imageSrc"
|
||||
class="group cursor-pointer overflow-hidden rounded-lg"
|
||||
@click="showPreview = true"
|
||||
>
|
||||
<img
|
||||
:src="imageSrc"
|
||||
:alt="fileName || '图片'"
|
||||
class="max-h-[240px] max-w-[280px] rounded-lg object-contain transition-transform duration-200 group-hover:scale-[1.02]"
|
||||
loading="lazy"
|
||||
@error="handleImageError"
|
||||
/>
|
||||
<!-- 悬停遮罩 -->
|
||||
<div
|
||||
class="absolute inset-0 flex items-center justify-center rounded-lg bg-black/0 transition-colors duration-200 group-hover:bg-black/10"
|
||||
>
|
||||
<span
|
||||
class="rounded-full bg-white/90 px-3 py-1 text-xs text-slate-700 opacity-0 shadow-sm transition-opacity duration-200 group-hover:opacity-100"
|
||||
>
|
||||
点击查看大图
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 加载失败 -->
|
||||
<div
|
||||
v-else
|
||||
class="flex flex-col items-center justify-center rounded-lg bg-slate-100 p-4"
|
||||
:style="{ minHeight: '80px', maxWidth: '280px' }"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-8 w-8 text-slate-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
<p class="mt-2 text-xs text-slate-500">图片加载失败</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 文件名(可选) -->
|
||||
<p
|
||||
v-if="fileName"
|
||||
class="mt-1.5 truncate text-xs"
|
||||
:class="isMe ? 'text-white/80' : 'text-slate-500'"
|
||||
:title="fileName"
|
||||
>
|
||||
{{ displayFileName }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 大图预览弹窗 -->
|
||||
<Teleport to="body">
|
||||
<Transition name="fade">
|
||||
<div
|
||||
v-if="showPreview"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/80 p-4"
|
||||
@click.self="showPreview = false"
|
||||
>
|
||||
<div class="relative max-h-[90vh] max-w-[90vw]">
|
||||
<img
|
||||
:src="imageSrc"
|
||||
:alt="fileName || '图片'"
|
||||
class="max-h-[90vh] max-w-[90vw] rounded-lg object-contain"
|
||||
/>
|
||||
<!-- 关闭按钮 -->
|
||||
<button
|
||||
class="absolute -right-3 -top-3 flex h-8 w-8 items-center justify-center rounded-full bg-white text-slate-600 shadow-elevated transition-colors hover:bg-slate-100"
|
||||
@click="showPreview = false"
|
||||
aria-label="关闭预览"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<!-- 下载按钮 -->
|
||||
<button
|
||||
v-if="imageSrc"
|
||||
class="absolute -bottom-3 left-1/2 -translate-x-1/2 flex items-center gap-1.5 rounded-full bg-white px-4 py-2 text-sm text-slate-700 shadow-elevated transition-colors hover:bg-slate-100"
|
||||
@click="handleDownload"
|
||||
aria-label="下载图片"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
|
||||
/>
|
||||
</svg>
|
||||
下载
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import { getAvatarLetter, getAvatarColorClass, getShortDisplayName } from '@/utils/avatar';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
/** 图片来源:blob URL 或 base64 data URL */
|
||||
imageSrc?: string;
|
||||
/** 文件名 */
|
||||
fileName?: string;
|
||||
/** 发送者名称 */
|
||||
senderName?: string;
|
||||
/** 发送者 ID */
|
||||
senderId?: string;
|
||||
/** 时间戳 */
|
||||
timestamp?: number;
|
||||
/** 是否是自己发送的 */
|
||||
isMe?: boolean;
|
||||
/** 状态 */
|
||||
status?: 'sending' | 'receiving' | 'done' | 'error';
|
||||
/** 进度 0-100 */
|
||||
progress?: number;
|
||||
}>(),
|
||||
{
|
||||
isMe: false,
|
||||
status: 'done',
|
||||
progress: 0,
|
||||
},
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
download: [];
|
||||
}>();
|
||||
|
||||
const showPreview = ref(false);
|
||||
const imageError = ref(false);
|
||||
|
||||
const displayName = computed(() => {
|
||||
if (props.senderName) return props.senderName;
|
||||
return props.isMe ? '我' : props.senderId ? `用户 ${props.senderId.slice(-6)}` : '未知';
|
||||
});
|
||||
|
||||
const shortDisplayName = computed(() => getShortDisplayName(displayName.value));
|
||||
const avatarLetter = computed(() => getAvatarLetter(displayName.value));
|
||||
const avatarColorClass = computed(() => getAvatarColorClass(displayName.value));
|
||||
|
||||
const displayFileName = computed(() => {
|
||||
const name = props.fileName || '未命名图片';
|
||||
if (name.length <= 24) return name;
|
||||
return name.slice(0, 10) + '…' + name.slice(-10);
|
||||
});
|
||||
|
||||
function formatTime(ts: number): string {
|
||||
const d = new Date(ts);
|
||||
const now = new Date();
|
||||
const isToday = d.toDateString() === now.toDateString();
|
||||
if (isToday) {
|
||||
return d.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
return d.toLocaleString('zh-CN', {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
function handleImageError() {
|
||||
imageError.value = true;
|
||||
}
|
||||
|
||||
function handleDownload() {
|
||||
emit('download');
|
||||
}
|
||||
|
||||
// ESC 键关闭预览
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape' && showPreview.value) {
|
||||
showPreview.value = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
103
frontend/src/components/MessageInput.vue
Normal file
103
frontend/src/components/MessageInput.vue
Normal file
@@ -0,0 +1,103 @@
|
||||
<template>
|
||||
<div class="border-t border-slate-200 bg-white">
|
||||
<div class="max-w-3xl mx-auto px-3 sm:px-4 py-3">
|
||||
<FileDropZone
|
||||
class="mb-2"
|
||||
@file-selected="emit('fileSelected', $event)"
|
||||
@text-pasted="handleTextPasted"
|
||||
/>
|
||||
<label class="block text-[11px] font-medium text-slate-700 mb-1">
|
||||
文本消息
|
||||
</label>
|
||||
<textarea
|
||||
ref="textareaRef"
|
||||
v-model="text"
|
||||
class="dt-textarea"
|
||||
rows="3"
|
||||
:placeholder="placeholder"
|
||||
:maxlength="maxLength"
|
||||
@keydown.enter.exact.prevent="handleSend"
|
||||
/>
|
||||
<div
|
||||
v-if="maxLength && text.length > maxLength * 0.9"
|
||||
class="mt-1 text-[11px] text-amber-600"
|
||||
>
|
||||
已 {{ text.length }} / {{ maxLength }} 字
|
||||
</div>
|
||||
<div
|
||||
class="mt-2 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 text-[11px] text-slate-500"
|
||||
>
|
||||
<span>Enter 发送 · Shift+Enter 换行 · Ctrl+V 粘贴</span>
|
||||
<BaseButton
|
||||
size="sm"
|
||||
variant="primary"
|
||||
:disabled="!canSend"
|
||||
@click="handleSend"
|
||||
>
|
||||
发送
|
||||
</BaseButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue';
|
||||
import BaseButton from '@/components/ui/BaseButton.vue';
|
||||
import FileDropZone from '@/components/FileDropZone.vue';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
/** 单条消息长度上限(建议,0 表示不限制) */
|
||||
maxLength?: number;
|
||||
placeholder?: string;
|
||||
}>(),
|
||||
{
|
||||
maxLength: 5 * 1024 * 1024,
|
||||
placeholder: '输入消息,Enter 发送,Shift+Enter 换行',
|
||||
},
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
send: [content: string];
|
||||
fileSelected: [files: File[]];
|
||||
}>();
|
||||
|
||||
const textareaRef = ref<HTMLTextAreaElement | null>(null);
|
||||
const text = ref('');
|
||||
|
||||
const canSend = computed(() => {
|
||||
const trimmed = text.value.trim();
|
||||
if (!trimmed) return false;
|
||||
if (props.maxLength && trimmed.length > props.maxLength) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
function handleSend() {
|
||||
const trimmed = text.value.trim();
|
||||
if (!trimmed) return;
|
||||
if (props.maxLength && trimmed.length > props.maxLength) return;
|
||||
emit('send', trimmed);
|
||||
text.value = '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理从剪贴板粘贴的文本
|
||||
* - 如果输入框为空,直接填充
|
||||
* - 如果输入框有内容,追加(用换行分隔)
|
||||
*/
|
||||
function handleTextPasted(pastedText: string) {
|
||||
if (!pastedText.trim()) return;
|
||||
|
||||
if (text.value.trim()) {
|
||||
// 已有内容,追加
|
||||
text.value = text.value + '\n' + pastedText;
|
||||
} else {
|
||||
// 无内容,直接填充
|
||||
text.value = pastedText;
|
||||
}
|
||||
|
||||
// 聚焦到输入框
|
||||
textareaRef.value?.focus();
|
||||
}
|
||||
</script>
|
||||
89
frontend/src/components/MessageItem.vue
Normal file
89
frontend/src/components/MessageItem.vue
Normal file
@@ -0,0 +1,89 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex w-full gap-3 items-end"
|
||||
:class="isMe ? 'flex-row-reverse' : ''"
|
||||
role="article"
|
||||
>
|
||||
<!-- 头像:他人左、自己右 -->
|
||||
<span
|
||||
class="inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-full text-xs font-medium text-white shadow-sm"
|
||||
:class="avatarColorClass"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{{ avatarLetter }}
|
||||
</span>
|
||||
<!-- 气泡:圆角、轻阴影、留白更足 -->
|
||||
<div
|
||||
class="max-w-[82%] rounded-2xl px-4 py-2.5 shadow-sm transition-shadow duration-200"
|
||||
:class="
|
||||
isMe
|
||||
? 'bg-primary text-white rounded-br-md'
|
||||
: 'bg-white text-slate-900 border border-slate-200/80 rounded-bl-md'
|
||||
"
|
||||
>
|
||||
<div class="flex items-center gap-2 mb-1.5">
|
||||
<span
|
||||
class="text-xs font-medium min-w-0 truncate"
|
||||
:class="isMe ? 'text-white/90' : 'text-slate-600'"
|
||||
>
|
||||
{{ shortDisplayName }}
|
||||
</span>
|
||||
<span
|
||||
v-if="timestamp"
|
||||
class="text-[11px] ml-auto shrink-0 tabular-nums"
|
||||
:class="isMe ? 'text-white/70' : 'text-slate-400'"
|
||||
>
|
||||
{{ formatTime(timestamp) }}
|
||||
</span>
|
||||
</div>
|
||||
<p
|
||||
class="text-[15px] leading-[1.55] break-words whitespace-pre-wrap"
|
||||
:class="isMe ? 'text-white' : 'text-slate-800'"
|
||||
>
|
||||
{{ content }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { getAvatarLetter, getAvatarColorClass, getShortDisplayName } from '@/utils/avatar';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
content: string;
|
||||
senderName?: string;
|
||||
senderId?: string;
|
||||
timestamp?: number;
|
||||
isMe?: boolean;
|
||||
}>(),
|
||||
{
|
||||
isMe: false,
|
||||
},
|
||||
);
|
||||
|
||||
const displayName = computed(() => {
|
||||
if (props.senderName) return props.senderName;
|
||||
return props.isMe ? '我' : (props.senderId ? `用户 ${props.senderId.slice(-6)}` : '未知');
|
||||
});
|
||||
|
||||
const shortDisplayName = computed(() => getShortDisplayName(displayName.value));
|
||||
const avatarLetter = computed(() => getAvatarLetter(displayName.value));
|
||||
const avatarColorClass = computed(() => getAvatarColorClass(displayName.value));
|
||||
|
||||
function formatTime(ts: number): string {
|
||||
const d = new Date(ts);
|
||||
const now = new Date();
|
||||
const isToday = d.toDateString() === now.toDateString();
|
||||
if (isToday) {
|
||||
return d.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
return d.toLocaleString('zh-CN', {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
</script>
|
||||
131
frontend/src/components/RoomPanel.vue
Normal file
131
frontend/src/components/RoomPanel.vue
Normal file
@@ -0,0 +1,131 @@
|
||||
<template>
|
||||
<aside
|
||||
class="flex flex-col border-r border-slate-200 bg-white h-full"
|
||||
aria-label="房间信息与操作"
|
||||
>
|
||||
<div class="px-4 py-3 border-b border-slate-200">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<h2 class="text-base font-semibold text-slate-900 truncate">
|
||||
房间 {{ roomCode }}
|
||||
</h2>
|
||||
<BaseButton
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
class="shrink-0"
|
||||
aria-label="复制房间号"
|
||||
@click="copyRoomCode"
|
||||
>
|
||||
复制
|
||||
</BaseButton>
|
||||
</div>
|
||||
<div class="mt-2 flex items-center gap-2">
|
||||
<StatusDot :status="connectionStatusDot" />
|
||||
<span class="text-xs text-slate-600">{{ connectionStatusLabel }}</span>
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-slate-500">临时房间 · 数据不落库</p>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-auto px-4 py-3 space-y-4">
|
||||
<!-- 在线用户 -->
|
||||
<UserList :user-list="userList" :my-user-id="myUserId" />
|
||||
|
||||
<!-- 历史与操作(doc10:隐私提示、清空、导出 JSON/文本) -->
|
||||
<section>
|
||||
<span class="text-xs font-medium text-slate-700 block mb-2">
|
||||
历史记录
|
||||
</span>
|
||||
<p class="text-xs text-slate-500 mb-2">
|
||||
数据仅存于本机,可随时清空。
|
||||
</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<BaseButton size="sm" variant="ghost" @click="$emit('clearHistory')">
|
||||
清空本地
|
||||
</BaseButton>
|
||||
<BaseButton size="sm" variant="ghost" @click="$emit('exportJson')">
|
||||
导出 JSON
|
||||
</BaseButton>
|
||||
<BaseButton size="sm" variant="ghost" @click="$emit('exportText')">
|
||||
导出文本
|
||||
</BaseButton>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="p-4 border-t border-slate-200">
|
||||
<BaseButton
|
||||
variant="danger"
|
||||
size="md"
|
||||
class="w-full"
|
||||
@click="$emit('leave')"
|
||||
>
|
||||
退出房间
|
||||
</BaseButton>
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import BaseButton from '@/components/ui/BaseButton.vue';
|
||||
import StatusDot from '@/components/ui/StatusDot.vue';
|
||||
import UserList from '@/components/UserList.vue';
|
||||
import type { SessionInfo } from '@/types/room';
|
||||
import type { ConnectionStatus } from '@/ws/RoomWsClient';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
roomCode: string;
|
||||
userList: SessionInfo[];
|
||||
connectionStatus: ConnectionStatus;
|
||||
myUserId: string;
|
||||
}>(),
|
||||
{
|
||||
userList: () => [],
|
||||
},
|
||||
);
|
||||
|
||||
defineEmits<{
|
||||
leave: [];
|
||||
clearHistory: [];
|
||||
exportJson: [];
|
||||
exportText: [];
|
||||
}>();
|
||||
|
||||
const connectionStatusDot = computed(() => {
|
||||
switch (props.connectionStatus) {
|
||||
case 'connecting':
|
||||
return 'warning';
|
||||
case 'connected':
|
||||
return 'online';
|
||||
case 'reconnecting':
|
||||
return 'reconnecting';
|
||||
case 'disconnected':
|
||||
default:
|
||||
return 'error';
|
||||
}
|
||||
});
|
||||
|
||||
const connectionStatusLabel = computed(() => {
|
||||
switch (props.connectionStatus) {
|
||||
case 'connecting':
|
||||
return '连接中…';
|
||||
case 'connected':
|
||||
return '已连接';
|
||||
case 'reconnecting':
|
||||
return '重连中…';
|
||||
case 'disconnected':
|
||||
default:
|
||||
return '已断开';
|
||||
}
|
||||
});
|
||||
|
||||
function copyRoomCode() {
|
||||
if (!props.roomCode) return;
|
||||
navigator.clipboard
|
||||
?.writeText(props.roomCode)
|
||||
.then(() => {
|
||||
// 可接入 Toast 提示“已复制”
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
</script>
|
||||
14
frontend/src/components/SystemMessage.vue
Normal file
14
frontend/src/components/SystemMessage.vue
Normal file
@@ -0,0 +1,14 @@
|
||||
<template>
|
||||
<div
|
||||
class="w-full py-2 px-4 rounded-xl bg-slate-200/60 text-center text-xs text-slate-600"
|
||||
role="status"
|
||||
>
|
||||
{{ message }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
message: string;
|
||||
}>();
|
||||
</script>
|
||||
63
frontend/src/components/UserList.vue
Normal file
63
frontend/src/components/UserList.vue
Normal file
@@ -0,0 +1,63 @@
|
||||
<template>
|
||||
<section>
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="text-xs font-medium text-slate-700">在线用户</span>
|
||||
<span class="text-xs text-slate-500">{{ userList.length }} 人</span>
|
||||
</div>
|
||||
<ul
|
||||
class="rounded-[var(--dt-radius-card)] border border-slate-200 divide-y divide-slate-100 overflow-hidden"
|
||||
role="list"
|
||||
aria-label="在线用户列表"
|
||||
>
|
||||
<li
|
||||
v-for="user in userList"
|
||||
:key="user.sessionId"
|
||||
class="flex items-center gap-2 px-3 py-2 text-sm transition-colors"
|
||||
:class="
|
||||
user.userId === myUserId
|
||||
? 'bg-blue-50 text-slate-900'
|
||||
: 'bg-white text-slate-700 hover:bg-slate-50'
|
||||
"
|
||||
>
|
||||
<!-- 用户名首字头像 -->
|
||||
<span
|
||||
class="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-[10px] font-medium text-white"
|
||||
:class="getAvatarColorClass(user.nickname || '匿名')"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{{ getAvatarLetter(user.nickname || '匿名') }}
|
||||
</span>
|
||||
<!-- 用户昵称(IP 只显示最后一段) -->
|
||||
<span class="truncate flex-1">
|
||||
{{ getShortDisplayName(user.nickname || '匿名') }}
|
||||
<span v-if="user.userId === myUserId" class="text-xs text-slate-500">(我)</span>
|
||||
</span>
|
||||
</li>
|
||||
<!-- 空状态 -->
|
||||
<li
|
||||
v-if="userList.length === 0"
|
||||
class="px-3 py-4 text-xs text-slate-500 text-center"
|
||||
>
|
||||
暂无用户在线
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { SessionInfo } from '@/types/room';
|
||||
import { getAvatarLetter, getAvatarColorClass, getShortDisplayName } from '@/utils/avatar';
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
/** 在线用户列表 */
|
||||
userList: SessionInfo[];
|
||||
/** 当前用户 ID,用于高亮显示自己 */
|
||||
myUserId: string;
|
||||
}>(),
|
||||
{
|
||||
userList: () => [],
|
||||
myUserId: '',
|
||||
},
|
||||
);
|
||||
</script>
|
||||
63
frontend/src/components/ui/BaseButton.vue
Normal file
63
frontend/src/components/ui/BaseButton.vue
Normal file
@@ -0,0 +1,63 @@
|
||||
<template>
|
||||
<button
|
||||
:type="type"
|
||||
class="inline-flex items-center justify-center font-medium transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 disabled:cursor-not-allowed"
|
||||
:class="buttonClass"
|
||||
:disabled="disabled || loading"
|
||||
>
|
||||
<span
|
||||
v-if="loading"
|
||||
class="mr-2 h-3 w-3 rounded-full border-2 border-white border-t-transparent animate-spin"
|
||||
></span>
|
||||
<slot />
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
|
||||
type ButtonVariant = 'primary' | 'secondary' | 'danger' | 'ghost';
|
||||
type ButtonSize = 'lg' | 'md' | 'sm';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
variant?: ButtonVariant;
|
||||
size?: ButtonSize;
|
||||
disabled?: boolean;
|
||||
loading?: boolean;
|
||||
type?: 'button' | 'submit' | 'reset';
|
||||
}>(),
|
||||
{
|
||||
variant: 'primary',
|
||||
size: 'md',
|
||||
disabled: false,
|
||||
loading: false,
|
||||
type: 'button',
|
||||
},
|
||||
);
|
||||
|
||||
const buttonClass = computed(() => {
|
||||
const base =
|
||||
'rounded-[var(--dt-radius-btn)] text-sm border border-transparent';
|
||||
|
||||
const variantMap: Record<ButtonVariant, string> = {
|
||||
primary:
|
||||
'bg-primary text-white hover:bg-primary-hover active:bg-primary-active disabled:bg-slate-300 disabled:text-slate-500',
|
||||
secondary:
|
||||
'bg-white text-primary border-primary hover:bg-primary/5 active:bg-primary/10 disabled:text-slate-400 disabled:border-slate-200',
|
||||
danger:
|
||||
'bg-danger text-white hover:bg-red-700 active:bg-red-800 disabled:bg-red-300',
|
||||
ghost:
|
||||
'bg-transparent text-primary hover:bg-primary/5 active:bg-primary/10 disabled:text-slate-400',
|
||||
};
|
||||
|
||||
const sizeMap: Record<ButtonSize, string> = {
|
||||
lg: 'h-11 px-5',
|
||||
md: 'h-9 px-4',
|
||||
sm: 'h-8 px-3 text-xs',
|
||||
};
|
||||
|
||||
return [base, variantMap[props.variant], sizeMap[props.size]].join(' ');
|
||||
});
|
||||
</script>
|
||||
|
||||
47
frontend/src/components/ui/StatusDot.vue
Normal file
47
frontend/src/components/ui/StatusDot.vue
Normal file
@@ -0,0 +1,47 @@
|
||||
<template>
|
||||
<span class="inline-flex items-center gap-1 text-xs">
|
||||
<span
|
||||
class="h-2.5 w-2.5 rounded-full"
|
||||
:class="dotClass"
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
<span class="text-slate-600">
|
||||
<slot />
|
||||
</span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
|
||||
/** 规范:连接中=黄、已连接=绿、重连中=橙、已断开=红 */
|
||||
type Status = 'online' | 'offline' | 'warning' | 'reconnecting' | 'error' | 'neutral';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
status?: Status;
|
||||
}>(),
|
||||
{
|
||||
status: 'neutral',
|
||||
},
|
||||
);
|
||||
|
||||
const dotClass = computed(() => {
|
||||
switch (props.status) {
|
||||
case 'online':
|
||||
return 'bg-green-500';
|
||||
case 'offline':
|
||||
return 'bg-slate-300';
|
||||
case 'warning':
|
||||
return 'bg-yellow-400';
|
||||
case 'reconnecting':
|
||||
return 'bg-orange-500';
|
||||
case 'error':
|
||||
return 'bg-red-500';
|
||||
case 'neutral':
|
||||
default:
|
||||
return 'bg-slate-400';
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
2
frontend/src/env.d.ts
vendored
Normal file
2
frontend/src/env.d.ts
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
13
frontend/src/main.ts
Normal file
13
frontend/src/main.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { createApp } from 'vue';
|
||||
import { createPinia } from 'pinia';
|
||||
import App from './App.vue';
|
||||
import router from './router';
|
||||
import './assets/main.css';
|
||||
|
||||
const app = createApp(App);
|
||||
|
||||
app.use(createPinia());
|
||||
app.use(router);
|
||||
|
||||
app.mount('#app');
|
||||
|
||||
25
frontend/src/router/index.ts
Normal file
25
frontend/src/router/index.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { createRouter, createWebHistory, RouteRecordRaw } from 'vue-router';
|
||||
import HomeView from '@/views/HomeView.vue';
|
||||
import RoomView from '@/views/RoomView.vue';
|
||||
|
||||
const routes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: '/',
|
||||
name: 'home',
|
||||
component: HomeView,
|
||||
},
|
||||
{
|
||||
path: '/room/:roomCode',
|
||||
name: 'room',
|
||||
component: RoomView,
|
||||
props: true,
|
||||
},
|
||||
];
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes,
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
757
frontend/src/stores/wsStore.ts
Normal file
757
frontend/src/stores/wsStore.ts
Normal file
@@ -0,0 +1,757 @@
|
||||
import type { IMessage } from '@stomp/stompjs';
|
||||
import { defineStore } from 'pinia';
|
||||
import { ref, watch } from 'vue';
|
||||
import { RoomWsClient, type ConnectionStatus } from '@/ws/RoomWsClient';
|
||||
import type { RoomMessagePayload, SessionInfo } from '@/types/room';
|
||||
import { isImageMimeType } from '@/types/room';
|
||||
import { uploadRoomFile } from '@/api/room';
|
||||
import { mergeChunksToBlob } from '@/utils/fileChunker';
|
||||
|
||||
const HISTORY_KEY_PREFIX = 'DataTool-history-';
|
||||
/** 历史记录最大条数,超出则淘汰最旧(doc10 容量) */
|
||||
const MAX_HISTORY_ITEMS = 500;
|
||||
/** 历史记录最大保留时间(毫秒),默认 7 天 */
|
||||
const MAX_HISTORY_AGE_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
/** 超过此长度则分片发送(与 doc05 一致,如 32KB) */
|
||||
const CHUNK_THRESHOLD = 32 * 1024;
|
||||
const CHUNK_SIZE = 32 * 1024;
|
||||
/** 分片缓存超时(ms),超时未收齐则清理并可选提示 */
|
||||
const CHUNK_CACHE_TTL = 60 * 1000;
|
||||
/** 小图直发阈值(doc07:200KB 以下直接发送 base64) */
|
||||
const IMAGE_INLINE_THRESHOLD = 200 * 1024;
|
||||
/** 单文件大小上限(100MB),与后端 transfer.max-file-size 一致 */
|
||||
const MAX_FILE_SIZE = 100 * 1024 * 1024;
|
||||
|
||||
interface JoinRoomPayload {
|
||||
roomCode: string;
|
||||
senderId?: string;
|
||||
nickname?: string;
|
||||
}
|
||||
|
||||
function randomUserId(): string {
|
||||
return `u_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
|
||||
}
|
||||
|
||||
function loadHistory(roomCode: string): RoomMessagePayload[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(HISTORY_KEY_PREFIX + roomCode);
|
||||
if (!raw) return [];
|
||||
return JSON.parse(raw) as RoomMessagePayload[];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function trimHistory(messages: RoomMessagePayload[]): RoomMessagePayload[] {
|
||||
const now = Date.now();
|
||||
let list = messages.filter((m) => {
|
||||
if (m.timestamp == null) return true;
|
||||
return m.timestamp >= now - MAX_HISTORY_AGE_MS;
|
||||
});
|
||||
if (list.length > MAX_HISTORY_ITEMS) {
|
||||
list = list.slice(-MAX_HISTORY_ITEMS);
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
function saveHistory(roomCode: string, messages: RoomMessagePayload[]): void {
|
||||
try {
|
||||
const trimmed = trimHistory(messages);
|
||||
localStorage.setItem(HISTORY_KEY_PREFIX + roomCode, JSON.stringify(trimmed));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
export const useWsStore = defineStore('ws', () => {
|
||||
const status = ref<ConnectionStatus>('disconnected');
|
||||
const client = ref<RoomWsClient | null>(null);
|
||||
const myUserId = ref<string>(randomUserId());
|
||||
const myNickname = ref<string>('匿名用户');
|
||||
const currentRoomCode = ref<string | null>(null);
|
||||
const userList = ref<SessionInfo[]>([]);
|
||||
const roomMessages = ref<RoomMessagePayload[]>([]);
|
||||
/** 文件接收进度 fileId -> 0-100,CHUNK 写入时更新 */
|
||||
const fileProgress = ref<Record<string, number>>({});
|
||||
/** 当前正在 HTTP 下载的 fileId(点击下载后置 true,完成后置 false,用于区分「未开始」与「0%」) */
|
||||
const downloadingFileIds = ref<Record<string, boolean>>({});
|
||||
/** 文件发送进度 fileId -> 0-100 */
|
||||
const sendingProgress = ref<Record<string, number>>({});
|
||||
/** 当前正在发送的 fileId,用于收到自己发的 FILE 时不重复追加 */
|
||||
const sendingFileIds = new Set<string>();
|
||||
/** 接收完成后 fileId -> object URL,用于下载 */
|
||||
const fileBlobUrls = ref<Record<string, string>>({});
|
||||
/** 图片消息的 blob URL 或 data URL:fileId -> URL */
|
||||
const imageBlobUrls = ref<Record<string, string>>({});
|
||||
/** 文件分片缓存:fileId -> { meta, chunks, receivedAt },收齐后合并为 Blob */
|
||||
const fileChunkCache = new Map<
|
||||
string,
|
||||
{
|
||||
meta: {
|
||||
fileName: string;
|
||||
fileSize: number;
|
||||
mimeType: string;
|
||||
totalChunks: number;
|
||||
senderId?: string;
|
||||
senderName?: string;
|
||||
};
|
||||
chunks: Map<number, string>;
|
||||
receivedAt: number;
|
||||
}
|
||||
>();
|
||||
/** 文本分片缓存:messageId -> 分片信息,用于重组 */
|
||||
const textChunkCache = new Map<
|
||||
string,
|
||||
{
|
||||
chunks: Map<number, string>;
|
||||
totalChunks: number;
|
||||
senderId?: string;
|
||||
senderName?: string;
|
||||
timestamp?: number;
|
||||
receivedAt: number;
|
||||
}
|
||||
>();
|
||||
|
||||
function init(baseUrl: string, endpoint = '/ws') {
|
||||
if (!client.value) {
|
||||
client.value = new RoomWsClient({ baseUrl, endpoint });
|
||||
}
|
||||
}
|
||||
|
||||
function connect() {
|
||||
if (!client.value) return;
|
||||
client.value.connect((s) => {
|
||||
status.value = s;
|
||||
});
|
||||
}
|
||||
|
||||
function disconnect() {
|
||||
Object.values(fileBlobUrls.value).forEach((url) => URL.revokeObjectURL(url));
|
||||
fileBlobUrls.value = {};
|
||||
// 清理图片 URL(只清理 blob URL,不清理 data URL)
|
||||
Object.entries(imageBlobUrls.value).forEach(([, url]) => {
|
||||
if (url.startsWith('blob:')) URL.revokeObjectURL(url);
|
||||
});
|
||||
imageBlobUrls.value = {};
|
||||
fileChunkCache.clear();
|
||||
sendingFileIds.clear();
|
||||
sendingProgress.value = {};
|
||||
client.value?.disconnect();
|
||||
status.value = 'disconnected';
|
||||
currentRoomCode.value = null;
|
||||
userList.value = [];
|
||||
roomMessages.value = [];
|
||||
}
|
||||
|
||||
function tryReassembleAndPush(messageId: string, roomCode: string): void {
|
||||
const entry = textChunkCache.get(messageId);
|
||||
if (!entry || entry.chunks.size !== entry.totalChunks) return;
|
||||
const sorted = Array.from(entry.chunks.entries()).sort((a, b) => a[0] - b[0]);
|
||||
const content = sorted.map(([, c]) => c).join('');
|
||||
const msg: RoomMessagePayload = {
|
||||
roomCode,
|
||||
type: 'TEXT',
|
||||
senderId: entry.senderId,
|
||||
senderName: entry.senderName,
|
||||
timestamp: entry.timestamp,
|
||||
content,
|
||||
};
|
||||
roomMessages.value.push(msg);
|
||||
textChunkCache.delete(messageId);
|
||||
if (currentRoomCode.value === roomCode) {
|
||||
saveHistory(roomCode, roomMessages.value);
|
||||
}
|
||||
}
|
||||
|
||||
function handleRoomMessage(msg: { body: string }) {
|
||||
try {
|
||||
const payload = JSON.parse(msg.body) as RoomMessagePayload;
|
||||
console.log('[WS] 收到消息:', payload.type, payload.fileId ?? '', payload);
|
||||
if (payload.type === 'SYSTEM' && payload.data) {
|
||||
if (payload.data.userList !== undefined) {
|
||||
userList.value = payload.data.userList;
|
||||
}
|
||||
roomMessages.value.push(payload);
|
||||
} else if (payload.type === 'TEXT') {
|
||||
if (payload.isChunk && payload.messageId != null && payload.chunkIndex != null && payload.totalChunks != null) {
|
||||
const messageId = payload.messageId;
|
||||
let entry = textChunkCache.get(messageId);
|
||||
if (!entry) {
|
||||
entry = {
|
||||
chunks: new Map(),
|
||||
totalChunks: payload.totalChunks,
|
||||
senderId: payload.senderId,
|
||||
senderName: payload.senderName,
|
||||
timestamp: payload.timestamp,
|
||||
receivedAt: Date.now(),
|
||||
};
|
||||
textChunkCache.set(messageId, entry);
|
||||
}
|
||||
entry.chunks.set(payload.chunkIndex, payload.content ?? '');
|
||||
entry.receivedAt = Date.now();
|
||||
tryReassembleAndPush(messageId, payload.roomCode ?? currentRoomCode.value ?? '');
|
||||
} else {
|
||||
roomMessages.value.push(payload);
|
||||
}
|
||||
} else if (payload.type === 'FILE' || payload.type === 'IMAGE') {
|
||||
const fileId = payload.fileId ?? '';
|
||||
const isOwnFile =
|
||||
payload.senderId === myUserId.value && sendingFileIds.has(fileId);
|
||||
if (!isOwnFile) {
|
||||
roomMessages.value.push(payload);
|
||||
// IMAGE 类型:小图直发,携带 base64 数据
|
||||
if (payload.type === 'IMAGE' && payload.imageData) {
|
||||
// 将 base64 转为 blob URL 以便图片组件显示
|
||||
const mimeType = payload.mimeType ?? 'image/png';
|
||||
const base64 = payload.imageData.startsWith('data:')
|
||||
? payload.imageData
|
||||
: `data:${mimeType};base64,${payload.imageData}`;
|
||||
imageBlobUrls.value[fileId] = base64;
|
||||
}
|
||||
// FILE 类型:仅当为 CHUNK 流(有 totalChunks 且非服务器存储)时初始化分片缓存;服务器文件下载走 HTTP
|
||||
if (
|
||||
payload.type === 'FILE' &&
|
||||
fileId &&
|
||||
payload.totalChunks != null &&
|
||||
payload.storage !== 'server'
|
||||
) {
|
||||
fileChunkCache.set(fileId, {
|
||||
meta: {
|
||||
fileName: payload.fileName ?? '未命名',
|
||||
fileSize: payload.fileSize ?? 0,
|
||||
mimeType: payload.mimeType ?? 'application/octet-stream',
|
||||
totalChunks: payload.totalChunks,
|
||||
senderId: payload.senderId,
|
||||
senderName: payload.senderName,
|
||||
},
|
||||
chunks: new Map(),
|
||||
receivedAt: Date.now(),
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (payload.type === 'CHUNK') {
|
||||
// 文件分片:写入缓存、更新进度、收齐后合并 Blob
|
||||
const fileId = payload.fileId ?? '';
|
||||
const chunkIndex = payload.chunkIndex;
|
||||
const content = (payload.content ?? payload.dataBase64) ?? '';
|
||||
console.log('[WS] CHUNK:', fileId, chunkIndex, '内容长度:', content.length);
|
||||
if (!fileId || chunkIndex == null || !content) {
|
||||
console.warn('[WS] CHUNK 缺少必要字段');
|
||||
return;
|
||||
}
|
||||
|
||||
const entry = fileChunkCache.get(fileId);
|
||||
if (!entry) {
|
||||
console.warn('[WS] CHUNK 找不到 entry,可能 FILE 消息还没到或已处理完');
|
||||
return;
|
||||
}
|
||||
|
||||
entry.chunks.set(chunkIndex, content);
|
||||
entry.receivedAt = Date.now();
|
||||
const received = entry.chunks.size;
|
||||
const total = entry.meta.totalChunks;
|
||||
fileProgress.value[fileId] = Math.min(
|
||||
100,
|
||||
Math.round((received / total) * 100),
|
||||
);
|
||||
|
||||
if (received === total) {
|
||||
const sorted = Array.from(entry.chunks.entries()).sort(
|
||||
(a, b) => a[0] - b[0],
|
||||
);
|
||||
const chunks = sorted.map(([, c]) => c);
|
||||
const blob = mergeChunksToBlob(chunks, entry.meta.mimeType);
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
fileBlobUrls.value[fileId] = blobUrl;
|
||||
// 如果是图片类型,也存入 imageBlobUrls 以便图片组件显示
|
||||
if (isImageMimeType(entry.meta.mimeType)) {
|
||||
imageBlobUrls.value[fileId] = blobUrl;
|
||||
}
|
||||
fileProgress.value[fileId] = 100;
|
||||
fileChunkCache.delete(fileId);
|
||||
if (currentRoomCode.value) {
|
||||
saveHistory(currentRoomCode.value, roomMessages.value);
|
||||
}
|
||||
}
|
||||
}
|
||||
// 仅对展示在列表中的消息持久化(TEXT 分片重组后由 tryReassembleAndPush 内保存)
|
||||
if (
|
||||
currentRoomCode.value &&
|
||||
(payload.type === 'SYSTEM' ||
|
||||
(payload.type === 'TEXT' && !payload.isChunk) ||
|
||||
payload.type === 'FILE' ||
|
||||
payload.type === 'IMAGE')
|
||||
) {
|
||||
saveHistory(currentRoomCode.value, roomMessages.value);
|
||||
}
|
||||
} catch {
|
||||
// ignore parse error
|
||||
}
|
||||
}
|
||||
|
||||
function handleChunkMessage(msg: IMessage) {
|
||||
try {
|
||||
const payload = JSON.parse(msg.body) as RoomMessagePayload;
|
||||
const fileId = payload.fileId ?? '';
|
||||
const chunkIndex = payload.chunkIndex;
|
||||
const content =
|
||||
(payload.content ?? payload.dataBase64) ?? '';
|
||||
if (!fileId || chunkIndex == null || !content) return;
|
||||
|
||||
const entry = fileChunkCache.get(fileId);
|
||||
if (!entry) return;
|
||||
|
||||
entry.chunks.set(chunkIndex, content);
|
||||
entry.receivedAt = Date.now();
|
||||
const received = entry.chunks.size;
|
||||
const total = entry.meta.totalChunks;
|
||||
fileProgress.value[fileId] = Math.min(
|
||||
100,
|
||||
Math.round((received / total) * 100),
|
||||
);
|
||||
|
||||
if (received === total) {
|
||||
const sorted = Array.from(entry.chunks.entries()).sort(
|
||||
(a, b) => a[0] - b[0],
|
||||
);
|
||||
const chunks = sorted.map(([, c]) => c);
|
||||
const blob = mergeChunksToBlob(chunks, entry.meta.mimeType);
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
fileBlobUrls.value[fileId] = blobUrl;
|
||||
// 如果是图片类型,也存入 imageBlobUrls 以便图片组件显示
|
||||
if (isImageMimeType(entry.meta.mimeType)) {
|
||||
imageBlobUrls.value[fileId] = blobUrl;
|
||||
}
|
||||
fileProgress.value[fileId] = 100;
|
||||
fileChunkCache.delete(fileId);
|
||||
if (currentRoomCode.value) {
|
||||
saveHistory(currentRoomCode.value, roomMessages.value);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore parse error
|
||||
}
|
||||
}
|
||||
|
||||
function joinRoom(payload: JoinRoomPayload) {
|
||||
if (!client.value) return;
|
||||
const nickname = payload.nickname ?? '匿名用户';
|
||||
myNickname.value = nickname;
|
||||
const isNewRoom = currentRoomCode.value !== payload.roomCode;
|
||||
if (isNewRoom) {
|
||||
userList.value = [];
|
||||
fileProgress.value = {};
|
||||
fileBlobUrls.value = {};
|
||||
imageBlobUrls.value = {};
|
||||
fileChunkCache.clear();
|
||||
roomMessages.value = loadHistory(payload.roomCode);
|
||||
}
|
||||
currentRoomCode.value = payload.roomCode;
|
||||
const senderId = payload.senderId ?? myUserId.value;
|
||||
client.value.joinRoom(
|
||||
payload.roomCode,
|
||||
{
|
||||
roomCode: payload.roomCode,
|
||||
type: 'SYSTEM',
|
||||
senderId,
|
||||
content: nickname,
|
||||
},
|
||||
handleRoomMessage,
|
||||
handleChunkMessage,
|
||||
);
|
||||
}
|
||||
|
||||
function leaveRoom(roomCode: string) {
|
||||
if (client.value && client.value.connectionStatus === 'connected') {
|
||||
client.value.leaveRoom(roomCode);
|
||||
}
|
||||
Object.values(fileBlobUrls.value).forEach((url) =>
|
||||
URL.revokeObjectURL(url),
|
||||
);
|
||||
fileBlobUrls.value = {};
|
||||
// 清理图片 URL
|
||||
Object.entries(imageBlobUrls.value).forEach(([, url]) => {
|
||||
if (url.startsWith('blob:')) URL.revokeObjectURL(url);
|
||||
});
|
||||
imageBlobUrls.value = {};
|
||||
fileChunkCache.clear();
|
||||
sendingFileIds.clear();
|
||||
sendingProgress.value = {};
|
||||
currentRoomCode.value = null;
|
||||
userList.value = [];
|
||||
roomMessages.value = [];
|
||||
fileProgress.value = {};
|
||||
}
|
||||
|
||||
function sendRoomMessage(roomCode: string, content: string) {
|
||||
if (!client.value) return;
|
||||
if (content.length <= CHUNK_THRESHOLD) {
|
||||
client.value.sendMessage(roomCode, {
|
||||
roomCode,
|
||||
type: 'TEXT',
|
||||
senderId: myUserId.value,
|
||||
senderName: myNickname.value,
|
||||
content,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const messageId = `${myUserId.value}_${Date.now()}`;
|
||||
const totalChunks = Math.ceil(content.length / CHUNK_SIZE);
|
||||
for (let i = 0; i < totalChunks; i++) {
|
||||
const start = i * CHUNK_SIZE;
|
||||
const chunk = content.slice(start, start + CHUNK_SIZE);
|
||||
client.value.sendMessage(roomCode, {
|
||||
roomCode,
|
||||
type: 'TEXT',
|
||||
senderId: myUserId.value,
|
||||
senderName: myNickname.value,
|
||||
content: chunk,
|
||||
isChunk: true,
|
||||
chunkIndex: i,
|
||||
totalChunks,
|
||||
messageId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function clearRoomHistory(roomCode: string): void {
|
||||
roomMessages.value = [];
|
||||
saveHistory(roomCode, []);
|
||||
}
|
||||
|
||||
function exportRoomHistory(roomCode: string): string {
|
||||
const data = JSON.stringify(roomMessages.value, null, 2);
|
||||
return URL.createObjectURL(new Blob([data], { type: 'application/json' }));
|
||||
}
|
||||
|
||||
/** 导出为纯文本,便于粘贴到工单/邮件(doc10) */
|
||||
function exportRoomHistoryAsText(roomCode: string): string {
|
||||
const lines: string[] = [
|
||||
`DataTool 房间 ${roomCode} 历史记录`,
|
||||
`导出时间: ${new Date().toLocaleString('zh-CN')}`,
|
||||
'---',
|
||||
];
|
||||
for (const m of roomMessages.value) {
|
||||
const time = m.timestamp
|
||||
? new Date(m.timestamp).toLocaleString('zh-CN')
|
||||
: '-';
|
||||
const sender = m.senderName ?? m.senderId ?? '系统';
|
||||
if (m.type === 'TEXT') {
|
||||
const content = (m.content ?? '').slice(0, 500);
|
||||
const suffix = (m.content?.length ?? 0) > 500 ? '…' : '';
|
||||
lines.push(`[${time}] ${sender} (文本)\n${content}${suffix}`);
|
||||
} else if (m.type === 'FILE' || m.type === 'IMAGE') {
|
||||
const size = m.fileSize != null ? ` ${(m.fileSize / 1024).toFixed(1)}KB` : '';
|
||||
lines.push(`[${time}] ${sender} (${m.type === 'IMAGE' ? '图片' : '文件'}): ${m.fileName ?? '未命名'}${size}`);
|
||||
} else if (m.type === 'SYSTEM' && m.data?.message) {
|
||||
lines.push(`[${time}] 系统: ${m.data.message}`);
|
||||
} else {
|
||||
lines.push(`[${time}] ${sender} (${m.type})`);
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
const text = lines.join('\n');
|
||||
return URL.createObjectURL(new Blob([text], { type: 'text/plain;charset=utf-8' }));
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 File 转换为 base64 字符串
|
||||
*/
|
||||
async function fileToBase64(file: File): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
const result = reader.result as string;
|
||||
// 移除 data:xxx;base64, 前缀,只保留纯 base64
|
||||
const base64 = result.split(',')[1] || result;
|
||||
resolve(base64);
|
||||
};
|
||||
reader.onerror = reject;
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
async function sendFile(
|
||||
roomCode: string,
|
||||
file: File,
|
||||
): Promise<{ ok: boolean; error?: string }> {
|
||||
if (!client.value) return { ok: false, error: '未连接' };
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
const limitMB = MAX_FILE_SIZE / (1024 * 1024);
|
||||
return {
|
||||
ok: false,
|
||||
error: `文件过大(${(file.size / (1024 * 1024)).toFixed(1)}MB),当前最大支持 ${limitMB}MB`,
|
||||
};
|
||||
}
|
||||
|
||||
const isImage = isImageMimeType(file.type);
|
||||
const isSmallImage = isImage && file.size <= IMAGE_INLINE_THRESHOLD;
|
||||
|
||||
// 小图直发:使用 IMAGE 类型,payload 携带 base64
|
||||
if (isSmallImage) {
|
||||
const fileId = `img_${Date.now()}_${Math.random().toString(36).slice(2, 11)}`;
|
||||
const imageData = await fileToBase64(file);
|
||||
sendingFileIds.add(fileId);
|
||||
|
||||
const optimistic: RoomMessagePayload = {
|
||||
roomCode,
|
||||
type: 'IMAGE',
|
||||
senderId: myUserId.value,
|
||||
senderName: myNickname.value,
|
||||
timestamp: Date.now(),
|
||||
fileId,
|
||||
fileName: file.name,
|
||||
fileSize: file.size,
|
||||
mimeType: file.type,
|
||||
imageData,
|
||||
};
|
||||
roomMessages.value.push(optimistic);
|
||||
|
||||
// 为自己发送的图片也创建 URL 以便显示
|
||||
const dataUrl = `data:${file.type};base64,${imageData}`;
|
||||
imageBlobUrls.value[fileId] = dataUrl;
|
||||
sendingProgress.value[fileId] = 100;
|
||||
|
||||
client.value.sendMessage(roomCode, {
|
||||
roomCode,
|
||||
type: 'IMAGE',
|
||||
senderId: myUserId.value,
|
||||
senderName: myNickname.value,
|
||||
timestamp: Date.now(),
|
||||
fileId,
|
||||
fileName: file.name,
|
||||
fileSize: file.size,
|
||||
mimeType: file.type,
|
||||
imageData,
|
||||
});
|
||||
|
||||
if (currentRoomCode.value === roomCode) {
|
||||
saveHistory(roomCode, roomMessages.value);
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
// 大文件/大图走 HTTP 上传到服务器,仅通过 WebSocket 广播 FILE 元数据,避免长传断连
|
||||
const tempFileId = `uploading_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
|
||||
sendingFileIds.add(tempFileId);
|
||||
sendingProgress.value[tempFileId] = 0;
|
||||
const optimistic: RoomMessagePayload = {
|
||||
roomCode,
|
||||
type: 'FILE',
|
||||
senderId: myUserId.value,
|
||||
senderName: myNickname.value,
|
||||
timestamp: Date.now(),
|
||||
fileId: tempFileId,
|
||||
fileName: file.name,
|
||||
fileSize: file.size,
|
||||
mimeType: file.type || 'application/octet-stream',
|
||||
storage: 'server',
|
||||
};
|
||||
roomMessages.value.push(optimistic);
|
||||
if (isImage) {
|
||||
imageBlobUrls.value[tempFileId] = URL.createObjectURL(file);
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await uploadRoomFile(roomCode, file, (percent) => {
|
||||
sendingProgress.value[tempFileId] = percent;
|
||||
sendingProgress.value = { ...sendingProgress.value };
|
||||
});
|
||||
const actualFileId = res.fileId;
|
||||
sendingFileIds.delete(tempFileId);
|
||||
sendingFileIds.add(actualFileId);
|
||||
sendingProgress.value[actualFileId] = 100;
|
||||
delete sendingProgress.value[tempFileId];
|
||||
|
||||
const payload: RoomMessagePayload = {
|
||||
...optimistic,
|
||||
fileId: actualFileId,
|
||||
fileName: res.fileName,
|
||||
fileSize: res.fileSize,
|
||||
mimeType: res.mimeType,
|
||||
storage: 'server',
|
||||
};
|
||||
const idx = roomMessages.value.findIndex(
|
||||
(m) => m.type === 'FILE' && m.fileId === tempFileId && m.senderId === myUserId.value,
|
||||
);
|
||||
if (idx !== -1) roomMessages.value[idx] = payload;
|
||||
if (isImage) {
|
||||
const url = imageBlobUrls.value[tempFileId];
|
||||
if (url) {
|
||||
imageBlobUrls.value[actualFileId] = url;
|
||||
delete imageBlobUrls.value[tempFileId];
|
||||
}
|
||||
}
|
||||
|
||||
client.value.sendMessage(roomCode, payload);
|
||||
|
||||
if (currentRoomCode.value === roomCode) {
|
||||
saveHistory(roomCode, roomMessages.value);
|
||||
}
|
||||
return { ok: true };
|
||||
} catch (e) {
|
||||
console.error('[sendFile] 上传失败:', e);
|
||||
sendingFileIds.delete(tempFileId);
|
||||
delete sendingProgress.value[tempFileId];
|
||||
const url = imageBlobUrls.value[tempFileId];
|
||||
if (url) {
|
||||
URL.revokeObjectURL(url);
|
||||
delete imageBlobUrls.value[tempFileId];
|
||||
}
|
||||
const removeIdx = roomMessages.value.findIndex(
|
||||
(m) => m.type === 'FILE' && m.fileId === tempFileId && m.senderId === myUserId.value,
|
||||
);
|
||||
if (removeIdx !== -1) roomMessages.value.splice(removeIdx, 1);
|
||||
const errMsg = e instanceof Error ? e.message : '发送失败,请重试';
|
||||
return { ok: false, error: errMsg };
|
||||
}
|
||||
}
|
||||
|
||||
function getFileProgress(fileId: string): number {
|
||||
const sent = sendingProgress.value[fileId];
|
||||
if (sent != null) return sent;
|
||||
return fileProgress.value[fileId] ?? 0;
|
||||
}
|
||||
|
||||
/** 设置文件进度(用于下载时在 UI 显示 接收中 X%) */
|
||||
function setFileProgress(fileId: string, percent: number): void {
|
||||
fileProgress.value[fileId] = percent;
|
||||
fileProgress.value = { ...fileProgress.value };
|
||||
}
|
||||
|
||||
/** 标记该 fileId 正在 HTTP 下载(仅此时接收方显示「接收中」进度条) */
|
||||
function setDownloading(fileId: string, downloading: boolean): void {
|
||||
if (downloading) {
|
||||
downloadingFileIds.value = { ...downloadingFileIds.value, [fileId]: true };
|
||||
} else {
|
||||
const next = { ...downloadingFileIds.value };
|
||||
delete next[fileId];
|
||||
downloadingFileIds.value = next;
|
||||
}
|
||||
}
|
||||
|
||||
function isDownloading(fileId: string): boolean {
|
||||
return !!downloadingFileIds.value[fileId];
|
||||
}
|
||||
|
||||
function getFileBlobUrl(fileId: string): string | null {
|
||||
return fileBlobUrls.value[fileId] ?? null;
|
||||
}
|
||||
|
||||
/** 是否为服务器存储文件(下载走 HTTP,无需 blob URL) */
|
||||
function isServerFile(msg: RoomMessagePayload): boolean {
|
||||
return msg.type === 'FILE' && (msg.storage === 'server' || msg.totalChunks == null);
|
||||
}
|
||||
|
||||
function revokeFileBlobUrl(fileId: string): void {
|
||||
const url = fileBlobUrls.value[fileId];
|
||||
if (url) {
|
||||
URL.revokeObjectURL(url);
|
||||
delete fileBlobUrls.value[fileId];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取图片的 URL(blob URL 或 data URL)
|
||||
*/
|
||||
function getImageUrl(fileId: string): string | null {
|
||||
return imageBlobUrls.value[fileId] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 释放图片 blob URL
|
||||
*/
|
||||
function revokeImageUrl(fileId: string): void {
|
||||
const url = imageBlobUrls.value[fileId];
|
||||
if (url && url.startsWith('blob:')) {
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
delete imageBlobUrls.value[fileId];
|
||||
}
|
||||
|
||||
/** 分片缓存超时清理:移除过期未收齐的 messageId/fileId,可选提示 */
|
||||
function cleanupChunkCaches(): void {
|
||||
const now = Date.now();
|
||||
const roomCode = currentRoomCode.value;
|
||||
|
||||
for (const [messageId, entry] of textChunkCache.entries()) {
|
||||
if (now - entry.receivedAt <= CHUNK_CACHE_TTL) continue;
|
||||
textChunkCache.delete(messageId);
|
||||
if (entry.chunks.size < entry.totalChunks && roomCode) {
|
||||
roomMessages.value.push({
|
||||
roomCode,
|
||||
type: 'SYSTEM',
|
||||
senderId: 'system',
|
||||
data: { message: '分片未完整接收' },
|
||||
});
|
||||
saveHistory(roomCode, roomMessages.value);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [fileId, entry] of fileChunkCache.entries()) {
|
||||
if (now - entry.receivedAt <= CHUNK_CACHE_TTL) continue;
|
||||
fileChunkCache.delete(fileId);
|
||||
if (entry.chunks.size < entry.meta.totalChunks && roomCode) {
|
||||
roomMessages.value.push({
|
||||
roomCode,
|
||||
type: 'SYSTEM',
|
||||
senderId: 'system',
|
||||
data: {
|
||||
message: `文件「${entry.meta.fileName}」未完整接收(发送方可能已断开)`,
|
||||
},
|
||||
});
|
||||
saveHistory(roomCode, roomMessages.value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const chunkCleanupInterval = setInterval(() => cleanupChunkCaches(), 10 * 1000);
|
||||
|
||||
// 重连恢复时插入系统提示(文档 15:已重新连接,已重新加入房间)
|
||||
const prevStatus = ref<ConnectionStatus>('disconnected');
|
||||
watch(
|
||||
status,
|
||||
(next) => {
|
||||
if (prevStatus.value === 'reconnecting' && next === 'connected' && currentRoomCode.value) {
|
||||
roomMessages.value.push({
|
||||
roomCode: currentRoomCode.value,
|
||||
type: 'SYSTEM',
|
||||
senderId: 'system',
|
||||
content: '',
|
||||
data: { message: '已重新连接,已重新加入房间' },
|
||||
});
|
||||
}
|
||||
prevStatus.value = next;
|
||||
},
|
||||
{ flush: 'sync' },
|
||||
);
|
||||
|
||||
return {
|
||||
status,
|
||||
myUserId,
|
||||
myNickname,
|
||||
currentRoomCode,
|
||||
userList,
|
||||
roomMessages,
|
||||
fileProgress,
|
||||
init,
|
||||
connect,
|
||||
disconnect,
|
||||
joinRoom,
|
||||
leaveRoom,
|
||||
sendRoomMessage,
|
||||
sendFile,
|
||||
clearRoomHistory,
|
||||
exportRoomHistory,
|
||||
exportRoomHistoryAsText,
|
||||
getFileProgress,
|
||||
setFileProgress,
|
||||
setDownloading,
|
||||
isDownloading,
|
||||
getFileBlobUrl,
|
||||
revokeFileBlobUrl,
|
||||
isServerFile,
|
||||
getImageUrl,
|
||||
revokeImageUrl,
|
||||
};
|
||||
});
|
||||
54
frontend/src/types/room.ts
Normal file
54
frontend/src/types/room.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* 房间与消息相关类型(与后端协议及 doc04 一致)
|
||||
*/
|
||||
|
||||
export interface SessionInfo {
|
||||
sessionId: string;
|
||||
userId: string;
|
||||
nickname: string;
|
||||
joinedAt: number | string; // epoch millis or ISO-8601
|
||||
}
|
||||
|
||||
export interface SystemMessageData {
|
||||
event?: 'USER_JOIN' | 'USER_LEAVE' | 'ERROR';
|
||||
message?: string;
|
||||
userList?: SessionInfo[];
|
||||
}
|
||||
|
||||
/** 统一消息模型:与 doc04 及后端 MessagePayload 对齐 */
|
||||
export interface RoomMessagePayload {
|
||||
type: 'TEXT' | 'FILE' | 'IMAGE' | 'SYSTEM' | 'CHUNK';
|
||||
roomCode?: string;
|
||||
senderId?: string;
|
||||
senderName?: string;
|
||||
timestamp?: number;
|
||||
/** 文本内容(TEXT) */
|
||||
content?: string;
|
||||
/** 文本分片(TEXT):是否为分片、重组用 messageId */
|
||||
isChunk?: boolean;
|
||||
messageId?: string;
|
||||
/** 系统消息(SYSTEM) */
|
||||
systemCode?: string;
|
||||
data?: SystemMessageData;
|
||||
/** 文件相关(FILE / CHUNK / IMAGE) */
|
||||
fileId?: string;
|
||||
fileName?: string;
|
||||
fileSize?: number;
|
||||
mimeType?: string;
|
||||
chunkIndex?: number;
|
||||
/** 分片总数;无此字段或 storage===server 表示服务器存储,下载走 HTTP */
|
||||
totalChunks?: number;
|
||||
/** 服务器存储文件,下载走 HTTP;无 totalChunks 时也视为服务器文件 */
|
||||
storage?: 'server';
|
||||
/** CHUNK 的 base64 数据,由后端/前端约定 */
|
||||
dataBase64?: string;
|
||||
/** IMAGE 类型专用:小图直发的 base64 数据(不带 data: 前缀或带前缀均可) */
|
||||
imageData?: string;
|
||||
}
|
||||
|
||||
/** 判断 mimeType 是否为图片类型 */
|
||||
export function isImageMimeType(mimeType?: string): boolean {
|
||||
return !!mimeType && mimeType.startsWith('image/');
|
||||
}
|
||||
|
||||
export type RoomMessage = RoomMessagePayload;
|
||||
88
frontend/src/utils/avatar.ts
Normal file
88
frontend/src/utils/avatar.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* 根据显示名生成头像文字与背景色,用于消息/用户列表等。
|
||||
* IP 时取最后一段(如 192.168.100.166 → 166)作为头像与显示名。
|
||||
*/
|
||||
|
||||
const AVATAR_COLORS = [
|
||||
'bg-blue-500',
|
||||
'bg-emerald-500',
|
||||
'bg-amber-500',
|
||||
'bg-violet-500',
|
||||
'bg-rose-500',
|
||||
'bg-cyan-500',
|
||||
'bg-orange-500',
|
||||
'bg-teal-500',
|
||||
] as const;
|
||||
|
||||
function hashString(s: string): number {
|
||||
let n = 0;
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
n = ((n * 31 + s.charCodeAt(i)) >>> 0) % 1e9;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
/** 简单判断是否为 IPv4 字符串(如 192.168.100.166) */
|
||||
export function isIPv4(s: string): boolean {
|
||||
if (!s || typeof s !== 'string') return false;
|
||||
const trimmed = s.trim();
|
||||
return /^\d{1,3}(\.\d{1,3}){3}$/.test(trimmed);
|
||||
}
|
||||
|
||||
/** 匹配纯 IPv4 或带后缀的 IPv4(如 192.168.100.166-2) */
|
||||
const IP_OPTIONAL_SUFFIX = /^(\d{1,3}(?:\.\d{1,3}){3})(-\d+)?$/;
|
||||
|
||||
/** 取 IPv4 最后一段,如 192.168.100.166 → 166;非 IP 返回原字符串 */
|
||||
export function getLastOctet(name: string): string {
|
||||
const trimmed = (name ?? '').trim();
|
||||
if (!trimmed) return trimmed;
|
||||
const m = trimmed.match(IP_OPTIONAL_SUFFIX);
|
||||
if (m) {
|
||||
const parts = m[1]!.split('.');
|
||||
return parts[parts.length - 1] ?? trimmed;
|
||||
}
|
||||
if (isIPv4(trimmed)) {
|
||||
const parts = trimmed.split('.');
|
||||
return parts[parts.length - 1] ?? trimmed;
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
/** 是否为 IP 或 IP-后缀 形式 */
|
||||
function isIPOrWithSuffix(s: string): boolean {
|
||||
return IP_OPTIONAL_SUFFIX.test((s ?? '').trim());
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示用短名:IP 只显示最后一段(166、128),带后缀则 166-2;非 IP 原样。
|
||||
*/
|
||||
export function getShortDisplayName(name: string): string {
|
||||
const trimmed = (name ?? '').trim();
|
||||
if (!trimmed) return trimmed;
|
||||
const m = trimmed.match(IP_OPTIONAL_SUFFIX);
|
||||
if (m) {
|
||||
const octet = m[1]!.split('.').pop() ?? m[1];
|
||||
return m[2] ? `${octet}${m[2]}` : octet;
|
||||
}
|
||||
if (isIPv4(trimmed)) return getLastOctet(trimmed);
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
/**
|
||||
* 头像文字:IP 取最后一段(166、128),否则取首字。
|
||||
*/
|
||||
export function getAvatarLetter(name: string): string {
|
||||
const trimmed = (name ?? '').trim();
|
||||
if (!trimmed) return '?';
|
||||
if (isIPOrWithSuffix(trimmed)) {
|
||||
const octet = getLastOctet(trimmed);
|
||||
return octet.length > 0 ? octet : '?';
|
||||
}
|
||||
return trimmed[0] ?? '?';
|
||||
}
|
||||
|
||||
/** 根据名称哈希返回固定 Tailwind 背景类,同一名称同色 */
|
||||
export function getAvatarColorClass(name: string): string {
|
||||
const n = hashString(name ?? '');
|
||||
return AVATAR_COLORS[n % AVATAR_COLORS.length];
|
||||
}
|
||||
168
frontend/src/utils/clipboard.ts
Normal file
168
frontend/src/utils/clipboard.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
/**
|
||||
* 剪贴板工具模块
|
||||
* 封装剪贴板读取权限判断与异常处理
|
||||
*/
|
||||
|
||||
export interface ClipboardReadResult {
|
||||
success: boolean;
|
||||
text?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查剪贴板 API 是否可用
|
||||
*/
|
||||
export function isClipboardApiAvailable(): boolean {
|
||||
return (
|
||||
typeof navigator !== 'undefined' &&
|
||||
typeof navigator.clipboard !== 'undefined' &&
|
||||
typeof navigator.clipboard.readText === 'function'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否在安全上下文中(HTTPS 或 localhost)
|
||||
*/
|
||||
export function isSecureContext(): boolean {
|
||||
if (typeof window === 'undefined') return false;
|
||||
// 使用浏览器提供的 isSecureContext 属性
|
||||
if (typeof window.isSecureContext === 'boolean') {
|
||||
return window.isSecureContext;
|
||||
}
|
||||
// 降级检查:localhost 或 HTTPS
|
||||
const { protocol, hostname } = window.location;
|
||||
return (
|
||||
protocol === 'https:' ||
|
||||
hostname === 'localhost' ||
|
||||
hostname === '127.0.0.1' ||
|
||||
hostname === '[::1]'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取剪贴板不可用的原因
|
||||
*/
|
||||
export function getClipboardUnavailableReason(): string | null {
|
||||
if (!isSecureContext()) {
|
||||
return '需要 HTTPS 或 localhost 环境才能读取剪贴板';
|
||||
}
|
||||
if (!isClipboardApiAvailable()) {
|
||||
return '当前浏览器不支持剪贴板 API';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 主动读取剪贴板文本
|
||||
* 注意:此操作需要用户手势触发(如点击按钮)
|
||||
*/
|
||||
export async function readClipboardText(): Promise<ClipboardReadResult> {
|
||||
// 检查是否在安全上下文
|
||||
if (!isSecureContext()) {
|
||||
return {
|
||||
success: false,
|
||||
error: '需要 HTTPS 或 localhost 环境才能读取剪贴板',
|
||||
};
|
||||
}
|
||||
|
||||
// 检查 API 是否可用
|
||||
if (!isClipboardApiAvailable()) {
|
||||
return {
|
||||
success: false,
|
||||
error: '当前浏览器不支持剪贴板 API,请使用 Ctrl+V 粘贴',
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const text = await navigator.clipboard.readText();
|
||||
if (!text || !text.trim()) {
|
||||
return {
|
||||
success: false,
|
||||
error: '剪贴板为空或不包含文本内容',
|
||||
};
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
text: text,
|
||||
};
|
||||
} catch (err) {
|
||||
// 常见错误:用户拒绝权限、浏览器策略限制等
|
||||
const message =
|
||||
err instanceof Error ? err.message : '未知错误';
|
||||
|
||||
// 根据错误类型给出友好提示
|
||||
if (message.includes('denied') || message.includes('permission')) {
|
||||
return {
|
||||
success: false,
|
||||
error: '剪贴板访问被拒绝,请在浏览器设置中允许访问或使用 Ctrl+V 粘贴',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: `无法读取剪贴板:${message},请使用 Ctrl+V 粘贴`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从粘贴事件中提取文件列表
|
||||
*/
|
||||
export function extractFilesFromPaste(e: ClipboardEvent): File[] {
|
||||
const items = e.clipboardData?.items;
|
||||
if (!items?.length) return [];
|
||||
|
||||
const files: File[] = [];
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const item = items[i];
|
||||
if (item.kind === 'file') {
|
||||
const file = item.getAsFile();
|
||||
if (file) files.push(file);
|
||||
}
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从粘贴事件中提取纯文本
|
||||
*/
|
||||
export function extractTextFromPaste(e: ClipboardEvent): string | null {
|
||||
const items = e.clipboardData?.items;
|
||||
if (!items?.length) return null;
|
||||
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const item = items[i];
|
||||
if (item.kind === 'string' && item.type === 'text/plain') {
|
||||
// 使用同步方式获取文本(通过 getData)
|
||||
return e.clipboardData?.getData('text/plain') ?? null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断粘贴事件是否包含文件
|
||||
*/
|
||||
export function pasteHasFiles(e: ClipboardEvent): boolean {
|
||||
const items = e.clipboardData?.items;
|
||||
if (!items?.length) return false;
|
||||
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
if (items[i].kind === 'file') return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断粘贴事件是否包含文本
|
||||
*/
|
||||
export function pasteHasText(e: ClipboardEvent): boolean {
|
||||
const items = e.clipboardData?.items;
|
||||
if (!items?.length) return false;
|
||||
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const item = items[i];
|
||||
if (item.kind === 'string' && item.type === 'text/plain') return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
97
frontend/src/utils/fileChunker.ts
Normal file
97
frontend/src/utils/fileChunker.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* 文件分片工具(文档 06:64KB 分片、Base64 发送)
|
||||
*/
|
||||
|
||||
const DEFAULT_CHUNK_SIZE = 64 * 1024; // 64KB
|
||||
|
||||
export interface FileChunkMeta {
|
||||
fileId: string;
|
||||
fileName: string;
|
||||
fileSize: number;
|
||||
mimeType: string;
|
||||
totalChunks: number;
|
||||
}
|
||||
|
||||
export interface ChunkResult {
|
||||
chunkIndex: number;
|
||||
data: string; // base64
|
||||
}
|
||||
|
||||
function arrayBufferToBase64(buffer: ArrayBuffer): string {
|
||||
const bytes = new Uint8Array(buffer);
|
||||
let binary = '';
|
||||
for (let i = 0; i < bytes.byteLength; i++) {
|
||||
binary += String.fromCharCode(bytes[i]);
|
||||
}
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
/**
|
||||
* 流式读取文件并逐片生成 base64,避免大文件一次性加载导致内存溢出。
|
||||
* 用于循环发送 CHUNK 到 /app/room/{roomCode}/file/chunk。
|
||||
*/
|
||||
export async function* readFileChunksStream(
|
||||
file: File,
|
||||
chunkSize: number = DEFAULT_CHUNK_SIZE,
|
||||
): AsyncGenerator<{ meta: FileChunkMeta } | { chunkIndex: number; data: string }> {
|
||||
const fileId = `f_${Date.now()}_${Math.random().toString(36).slice(2, 11)}`;
|
||||
const totalChunks = Math.ceil(file.size / chunkSize);
|
||||
const meta: FileChunkMeta = {
|
||||
fileId,
|
||||
fileName: file.name,
|
||||
fileSize: file.size,
|
||||
mimeType: file.type || 'application/octet-stream',
|
||||
totalChunks,
|
||||
};
|
||||
yield { meta };
|
||||
|
||||
for (let i = 0; i < totalChunks; i++) {
|
||||
const start = i * chunkSize;
|
||||
const end = Math.min(start + chunkSize, file.size);
|
||||
const blob = file.slice(start, end);
|
||||
const buffer = await blob.arrayBuffer();
|
||||
const data = arrayBufferToBase64(buffer);
|
||||
yield { chunkIndex: i, data };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 File 按 chunkSize 切片,返回 base64 字符串数组。
|
||||
* 注意:会一次性加载整个文件到内存,大文件请使用 readFileChunksStream。
|
||||
*/
|
||||
export async function getFileChunks(
|
||||
file: File,
|
||||
chunkSize: number = DEFAULT_CHUNK_SIZE,
|
||||
): Promise<{ meta: FileChunkMeta; chunks: string[] }> {
|
||||
const chunks: string[] = [];
|
||||
let meta: FileChunkMeta | null = null;
|
||||
for await (const item of readFileChunksStream(file, chunkSize)) {
|
||||
if ('meta' in item) meta = item.meta;
|
||||
else chunks.push(item.data);
|
||||
}
|
||||
return { meta: meta!, chunks };
|
||||
}
|
||||
|
||||
/**
|
||||
* 将按顺序的 base64 分片合并为 Blob。
|
||||
* 用于接收端收齐 CHUNK 后本地重组。
|
||||
*/
|
||||
export function mergeChunksToBlob(chunks: string[], mimeType: string): Blob {
|
||||
const parts: Uint8Array[] = [];
|
||||
for (const b64 of chunks) {
|
||||
const binary = atob(b64);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i++) {
|
||||
bytes[i] = binary.charCodeAt(i);
|
||||
}
|
||||
parts.push(bytes);
|
||||
}
|
||||
const totalLength = parts.reduce((acc, p) => acc + p.length, 0);
|
||||
const merged = new Uint8Array(totalLength);
|
||||
let offset = 0;
|
||||
for (const p of parts) {
|
||||
merged.set(p, offset);
|
||||
offset += p.length;
|
||||
}
|
||||
return new Blob([merged], { type: mimeType });
|
||||
}
|
||||
124
frontend/src/views/HomeView.vue
Normal file
124
frontend/src/views/HomeView.vue
Normal file
@@ -0,0 +1,124 @@
|
||||
<template>
|
||||
<div class="flex items-center justify-center min-h-[calc(100vh-56px)] px-4">
|
||||
<div
|
||||
class="w-full max-w-4xl bg-white border border-slate-200 rounded-[var(--dt-radius-modal)] px-6 sm:px-8 py-10 grid gap-8 md:grid-cols-2"
|
||||
>
|
||||
<!-- 创建房间 -->
|
||||
<section class="space-y-4">
|
||||
<h2 class="text-xl font-semibold text-slate-900">创建房间</h2>
|
||||
<p class="text-sm text-slate-600">
|
||||
自动生成 6 位房间号,在本地浏览器会话之间快速传文本、文件和图片。
|
||||
</p>
|
||||
<div
|
||||
class="mt-4 flex items-center justify-between rounded-[var(--dt-radius-card)] border border-dashed border-slate-300 bg-slate-50 px-4 py-3"
|
||||
>
|
||||
<span class="text-2xl font-mono font-semibold text-slate-900">
|
||||
{{ createRoomCodeDisplay }}
|
||||
</span>
|
||||
<BaseButton
|
||||
size="lg"
|
||||
variant="primary"
|
||||
:loading="createLoading"
|
||||
:disabled="createLoading"
|
||||
@click="handleCreateAndEnter"
|
||||
>
|
||||
创建并进入
|
||||
</BaseButton>
|
||||
</div>
|
||||
<p v-if="createError" class="text-xs text-danger">
|
||||
{{ createError }}
|
||||
</p>
|
||||
<p class="text-xs text-slate-500">
|
||||
数据不落库,仅当前会话可见。关闭页面后历史记录仅保留在本地浏览器。
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<!-- 加入房间 -->
|
||||
<section class="space-y-4">
|
||||
<h2 class="text-xl font-semibold text-slate-900">加入房间</h2>
|
||||
<p class="text-sm text-slate-600">
|
||||
输入房间号,与同一房间的其他终端进行数据同步传输。
|
||||
</p>
|
||||
<form class="space-y-3" @submit.prevent="handleJoin">
|
||||
<div class="space-y-1.5">
|
||||
<label class="block text-xs font-medium text-slate-700">
|
||||
房间号(6 位数字)
|
||||
</label>
|
||||
<input
|
||||
v-model="joinRoomCode"
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
maxlength="6"
|
||||
class="dt-input"
|
||||
:class="{ 'border-danger focus:border-danger': joinError }"
|
||||
placeholder="请输入 6 位数字房间号"
|
||||
@input="joinError = ''"
|
||||
/>
|
||||
<p v-if="joinError" class="text-xs text-danger">
|
||||
{{ joinError }}
|
||||
</p>
|
||||
</div>
|
||||
<BaseButton
|
||||
type="submit"
|
||||
size="lg"
|
||||
variant="primary"
|
||||
class="w-full"
|
||||
:disabled="!isJoinValid"
|
||||
>
|
||||
加入房间
|
||||
</BaseButton>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import axios from 'axios';
|
||||
import BaseButton from '@/components/ui/BaseButton.vue';
|
||||
|
||||
// 开发时用空字符串走同源,由 Vite 代理到后端,便于其它设备通过本机 IP:5173 访问
|
||||
const API_BASE = import.meta.env.VITE_API_BASE ?? (import.meta.env.DEV ? '' : 'http://localhost:8080');
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const createRoomCodeDisplay = ref('------');
|
||||
const createLoading = ref(false);
|
||||
const createError = ref('');
|
||||
const joinRoomCode = ref('');
|
||||
const joinError = ref('');
|
||||
|
||||
const isJoinValid = computed(() => /^[0-9]{6}$/.test(joinRoomCode.value));
|
||||
|
||||
async function handleCreateAndEnter() {
|
||||
createError.value = '';
|
||||
createLoading.value = true;
|
||||
try {
|
||||
const { data } = await axios.post<{ roomCode: string }>(
|
||||
`${API_BASE}/api/room/create`,
|
||||
);
|
||||
const roomCode = data.roomCode;
|
||||
createRoomCodeDisplay.value = roomCode;
|
||||
createLoading.value = false;
|
||||
router.push({ name: 'room', params: { roomCode } });
|
||||
} catch {
|
||||
createError.value = '创建房间失败,请稍后重试。';
|
||||
createRoomCodeDisplay.value = String(
|
||||
Math.floor(100000 + Math.random() * 900000),
|
||||
);
|
||||
createLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleJoin() {
|
||||
joinError.value = '';
|
||||
if (!isJoinValid.value) {
|
||||
joinError.value = '房间号必须是 6 位数字。';
|
||||
return;
|
||||
}
|
||||
router.push({ name: 'room', params: { roomCode: joinRoomCode.value } });
|
||||
}
|
||||
</script>
|
||||
|
||||
332
frontend/src/views/RoomView.vue
Normal file
332
frontend/src/views/RoomView.vue
Normal 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;
|
||||
// 发送方: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>
|
||||
114
frontend/src/ws/RoomWsClient.ts
Normal file
114
frontend/src/ws/RoomWsClient.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import type { IMessage, StompSubscription } from '@stomp/stompjs';
|
||||
import {
|
||||
createWebSocketClient,
|
||||
type ConnectionStatus,
|
||||
type WebSocketApi,
|
||||
} from '@/api/websocket';
|
||||
|
||||
export type { ConnectionStatus };
|
||||
|
||||
export interface WsConfig {
|
||||
baseUrl: string;
|
||||
endpoint: string;
|
||||
}
|
||||
|
||||
export type MessageHandler = (message: IMessage) => void;
|
||||
|
||||
export class RoomWsClient {
|
||||
private api: WebSocketApi;
|
||||
private roomSubscriptions: StompSubscription[] = [];
|
||||
private readonly config: WsConfig;
|
||||
|
||||
constructor(config: WsConfig) {
|
||||
this.config = config;
|
||||
this.api = createWebSocketClient();
|
||||
}
|
||||
|
||||
get connectionStatus(): ConnectionStatus {
|
||||
return this.api.getStatus();
|
||||
}
|
||||
|
||||
connect(onStatusChange?: (status: ConnectionStatus) => void): void {
|
||||
if (this.api.getStatus() === 'connected') return;
|
||||
|
||||
const socketUrl = `${this.config.baseUrl}${this.config.endpoint}`;
|
||||
this.api.connect(
|
||||
socketUrl,
|
||||
() => {
|
||||
// 连接/重连成功:由 wsStore watch status 触发 joinRoom,重新 subscribe + join
|
||||
},
|
||||
() => {
|
||||
onStatusChange?.('disconnected');
|
||||
},
|
||||
onStatusChange,
|
||||
);
|
||||
}
|
||||
|
||||
disconnect(): void {
|
||||
this.roomSubscriptions.forEach((s) => {
|
||||
try {
|
||||
s.unsubscribe();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
});
|
||||
this.roomSubscriptions = [];
|
||||
this.api.disconnect();
|
||||
}
|
||||
|
||||
/** CHUNK 回调:收到 /topic/room/{roomCode}/file/{fileId} 的消息,body 为 JSON(fileId/chunkIndex/content) */
|
||||
joinRoom(
|
||||
roomCode: string,
|
||||
payload: unknown,
|
||||
onMessage: MessageHandler,
|
||||
onFileChunk?: (message: IMessage) => void,
|
||||
): void {
|
||||
if (this.api.getStatus() !== 'connected') return;
|
||||
|
||||
// 重连后旧订阅已失效,清空本地引用
|
||||
this.roomSubscriptions.forEach((s) => {
|
||||
try {
|
||||
s.unsubscribe();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
});
|
||||
this.roomSubscriptions = [];
|
||||
|
||||
const roomSub = this.api.subscribe(`/topic/room/${roomCode}`, onMessage);
|
||||
if (roomSub) this.roomSubscriptions.push(roomSub);
|
||||
|
||||
if (onFileChunk) {
|
||||
const fileSub = this.api.subscribe(
|
||||
`/topic/room/${roomCode}/file/*`,
|
||||
onFileChunk,
|
||||
);
|
||||
if (fileSub) this.roomSubscriptions.push(fileSub);
|
||||
}
|
||||
|
||||
this.api.send(`/app/room/${roomCode}/join`, payload as object);
|
||||
}
|
||||
|
||||
leaveRoom(roomCode: string): void {
|
||||
if (this.api.getStatus() !== 'connected') return;
|
||||
this.api.send(`/app/room/${roomCode}/leave`, {});
|
||||
this.roomSubscriptions.forEach((s) => {
|
||||
try {
|
||||
s.unsubscribe();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
});
|
||||
this.roomSubscriptions = [];
|
||||
}
|
||||
|
||||
sendMessage(roomCode: string, payload: unknown): void {
|
||||
if (this.api.getStatus() !== 'connected') return;
|
||||
this.api.send(`/app/room/${roomCode}/message`, payload as object);
|
||||
}
|
||||
|
||||
sendFileChunk(roomCode: string, payload: unknown): void {
|
||||
if (this.api.getStatus() !== 'connected') return;
|
||||
this.api.send(`/app/room/${roomCode}/file/chunk`, payload as object);
|
||||
}
|
||||
}
|
||||
36
frontend/tailwind.config.cjs
Normal file
36
frontend/tailwind.config.cjs
Normal file
@@ -0,0 +1,36 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
content: ['./index.html', './src/**/*.{vue,ts,tsx}'],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
primary: {
|
||||
DEFAULT: '#2563EB', // blue-600
|
||||
hover: '#1D4ED8', // blue-700
|
||||
active: '#1E40AF', // blue-800
|
||||
},
|
||||
success: '#16A34A', // green-600
|
||||
danger: '#DC2626', // red-600
|
||||
warning: '#F97316', // orange-500
|
||||
},
|
||||
borderRadius: {
|
||||
btn: '4px',
|
||||
card: '8px',
|
||||
modal: '12px',
|
||||
},
|
||||
spacing: {
|
||||
1: '4px',
|
||||
2: '8px',
|
||||
3: '12px',
|
||||
4: '16px',
|
||||
6: '24px',
|
||||
8: '32px',
|
||||
},
|
||||
boxShadow: {
|
||||
'elevated': '0 10px 25px rgba(15,23,42,0.08)',
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
|
||||
21
frontend/tsconfig.json
Normal file
21
frontend/tsconfig.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"useDefineForClassFields": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"jsx": "preserve",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"esModuleInterop": true,
|
||||
"lib": ["ESNext", "DOM"],
|
||||
"skipLibCheck": true,
|
||||
"types": ["vite/client"],
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"]
|
||||
}
|
||||
32
frontend/vite.config.ts
Normal file
32
frontend/vite.config.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import vue from '@vitejs/plugin-vue';
|
||||
import path from 'path';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, 'src'),
|
||||
},
|
||||
},
|
||||
// 为 sockjs-client 等依赖提供浏览器环境下的 global 替代
|
||||
define: {
|
||||
global: 'window',
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
// 允许局域网内其它设备访问(如手机、其它电脑)
|
||||
host: true,
|
||||
// 开发时代理:前端用同源请求,由 Vite 转发到后端,跨设备访问时无需改 API 地址
|
||||
// xfwd: true 会把真实客户端 IP 写入 X-Forwarded-For,后端 /api/room/my-ip 才能拿到各机器的 IP
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8080',
|
||||
changeOrigin: true,
|
||||
xfwd: true,
|
||||
},
|
||||
'/ws': { target: 'http://localhost:8080', ws: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user