Initial commit: DataTool backend, frontend and Docker

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

13
frontend/index.html Normal file
View 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

File diff suppressed because it is too large Load Diff

28
frontend/package.json Normal file
View 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"
}
}

View File

@@ -0,0 +1,7 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

60
frontend/src/App.vue Normal file
View 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
View File

@@ -0,0 +1,111 @@
/**
* 房间相关 REST API文件上传与下载 URL。
* 大文件走 HTTP 上传/下载,避免 WebSocket 长传断连。
*/
export interface UploadFileResponse {
fileId: string;
fileName: string;
fileSize: number;
mimeType: string;
}
/**
* 上传文件到房间,落盘到服务器,返回文件元数据。
* onProgress(0100):上传进度;当 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(0100) 在 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();
});
}

View 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,
};
}

View 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;
}

View 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>

View 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; // 0100
}>(),
{
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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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
View File

@@ -0,0 +1,2 @@
/// <reference types="vite/client" />

13
frontend/src/main.ts Normal file
View 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');

View 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;

View 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;
/** 小图直发阈值doc07200KB 以下直接发送 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-100CHUNK 写入时更新 */
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 URLfileId -> 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];
}
}
/**
* 获取图片的 URLblob 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,
};
});

View 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;

View 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];
}

View 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;
}

View File

@@ -0,0 +1,97 @@
/**
* 文件分片工具(文档 0664KB 分片、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 });
}

View 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>

View File

@@ -0,0 +1,332 @@
<template>
<div class="h-[calc(100vh-56px)] flex flex-col lg:flex-row">
<!-- 窄屏顶部栏显示房间号与退出 -->
<div
class="lg:hidden flex items-center justify-between px-4 py-2 border-b border-slate-200 bg-white shrink-0"
>
<span class="font-mono font-semibold text-slate-900">房间 {{ roomCode }}</span>
<BaseButton size="sm" variant="danger" @click="handleLeave">
退出
</BaseButton>
</div>
<!-- 左侧 RoomPanel房间号在线用户退出/清空/导出 -->
<div class="hidden lg:flex lg:w-80 lg:shrink-0">
<RoomPanel
:room-code="roomCode"
:user-list="wsStore.userList"
:connection-status="wsStore.status"
:my-user-id="wsStore.myUserId"
@leave="handleLeave"
@clear-history="handleClearHistory"
@export-json="handleExportJson"
@export-text="handleExportText"
/>
</div>
<!-- 右侧消息与输入区域 -->
<section class="flex-1 flex flex-col bg-slate-100/80 min-w-0">
<div ref="messageListRef" class="flex-1 overflow-auto px-4 sm:px-6 py-4">
<div class="max-w-2xl mx-auto space-y-4">
<template v-for="(msg, index) in wsStore.roomMessages" :key="messageKey(msg, index)">
<SystemMessage
v-if="msg.type === 'SYSTEM' && getSystemMessageText(msg)"
:message="getSystemMessageText(msg)!"
/>
<MessageItem
v-else-if="msg.type === 'TEXT'"
:content="msg.content ?? ''"
:sender-name="msg.senderName ?? resolveSenderName(msg.senderId)"
:sender-id="msg.senderId"
:timestamp="msg.timestamp"
:is-me="msg.senderId === wsStore.myUserId"
/>
<!-- 图片消息IMAGE 类型或 FILE 类型但 mimeType 为图片 -->
<ImageMessage
v-else-if="isImageMessage(msg)"
:image-src="getImageSrc(msg)"
:file-name="msg.fileName"
:sender-name="msg.senderName ?? resolveSenderName(msg.senderId)"
:sender-id="msg.senderId"
:timestamp="msg.timestamp"
:is-me="msg.senderId === wsStore.myUserId"
:status="getFileStatus(msg)"
:progress="wsStore.getFileProgress(msg.fileId ?? '')"
@download="handleImageDownload(msg)"
/>
<!-- 普通文件消息 -->
<FileMessage
v-else-if="msg.type === 'FILE'"
:file-name="msg.fileName ?? '未命名'"
:file-size="msg.fileSize ?? 0"
:sender-name="msg.senderName ?? resolveSenderName(msg.senderId)"
:is-me="msg.senderId === wsStore.myUserId"
:status="getFileStatus(msg)"
:progress="wsStore.getFileProgress(msg.fileId ?? '')"
@download="handleFileDownload(msg)"
/>
</template>
</div>
</div>
<MessageInput @send="handleSend" @file-selected="handleFileSelected" />
<!-- 下载提示 Toast URL 时说明原因 -->
<Transition name="fade">
<div
v-if="downloadToast"
class="fixed bottom-20 left-1/2 -translate-x-1/2 rounded-[var(--dt-radius-card)] border border-slate-200 bg-white px-4 py-2 text-sm text-slate-700 shadow-elevated"
role="status"
>
{{ downloadToast }}
</div>
</Transition>
</section>
</div>
</template>
<script setup lang="ts">
import { ref, watch, onMounted } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import BaseButton from '@/components/ui/BaseButton.vue';
import RoomPanel from '@/components/RoomPanel.vue';
import SystemMessage from '@/components/SystemMessage.vue';
import MessageItem from '@/components/MessageItem.vue';
import FileMessage from '@/components/FileMessage.vue';
import ImageMessage from '@/components/ImageMessage.vue';
import MessageInput from '@/components/MessageInput.vue';
import { getFileDownloadUrl, downloadWithProgress, getMyIp } from '@/api/room';
import { useWsStore } from '@/stores/wsStore';
import type { RoomMessagePayload } from '@/types/room';
import { isImageMimeType } from '@/types/room';
// 开发时用空字符串走同源,由 Vite 代理到后端,便于其它设备通过本机 IP:5173 访问
const API_BASE = import.meta.env.VITE_API_BASE ?? (import.meta.env.DEV ? '' : 'http://localhost:8080');
const route = useRoute();
const router = useRouter();
const roomCode = route.params.roomCode as string;
const messageListRef = ref<HTMLElement | null>(null);
const wsStore = useWsStore();
const downloadToast = ref('');
let downloadToastTimer: ReturnType<typeof setTimeout> | null = null;
function showDownloadToast(message: string) {
downloadToast.value = message;
if (downloadToastTimer) clearTimeout(downloadToastTimer);
downloadToastTimer = setTimeout(() => {
downloadToast.value = '';
downloadToastTimer = null;
}, 3000);
}
onMounted(() => {
wsStore.init(API_BASE, '/ws');
wsStore.connect();
});
watch(
() => wsStore.status,
async (status) => {
if (status === 'connected' && roomCode) {
try {
const ip = await getMyIp(API_BASE);
wsStore.joinRoom({ roomCode, nickname: ip?.trim() || '访客' });
} catch {
wsStore.joinRoom({ roomCode, nickname: '访客' });
}
}
},
{ immediate: true },
);
function getSystemMessageText(msg: RoomMessagePayload): string | undefined {
if (msg.type !== 'SYSTEM' || !msg.data) return undefined;
const data = msg.data as { message?: string };
return data.message;
}
function messageKey(msg: RoomMessagePayload, index: number): string {
if ((msg.type === 'FILE' || msg.type === 'IMAGE') && msg.fileId)
return `file-${msg.fileId}`;
if (msg.timestamp && msg.senderId)
return `${msg.type}-${msg.senderId}-${msg.timestamp}-${index}`;
return `msg-${index}`;
}
function resolveSenderName(senderId?: string): string {
if (!senderId) return '未知';
if (senderId === wsStore.myUserId) return wsStore.myNickname;
const user = wsStore.userList.find((u) => u.userId === senderId);
return user?.nickname ?? senderId.slice(-8);
}
function getFileStatus(msg: RoomMessagePayload): 'sending' | 'receiving' | 'done' | 'error' {
const fileId = msg.fileId ?? '';
const progress = wsStore.getFileProgress(fileId);
const isMe = msg.senderId === wsStore.myUserId;
// 发送方099 显示「发送中」
if (isMe && progress >= 0 && progress < 100) return 'sending';
// 接收方仅当已点击下载isDownloading且 099 时显示「接收中」,否则显示「下载」按钮
if (!isMe && progress >= 0 && progress < 100 && wsStore.isDownloading(fileId)) return 'receiving';
return 'done';
}
/**
* 判断消息是否为图片类型
* - IMAGE 类型(小图直发)
* - FILE 类型但 mimeType 为 image/*
*/
function isImageMessage(msg: RoomMessagePayload): boolean {
if (msg.type === 'IMAGE') return true;
if (msg.type === 'FILE' && isImageMimeType(msg.mimeType)) return true;
return false;
}
/**
* 获取图片消息的显示 URL
*/
function getImageSrc(msg: RoomMessagePayload): string | undefined {
const fileId = msg.fileId ?? '';
// 优先从 store 获取(包含接收完成的 blob URL 和小图直发的 data URL
const storeUrl = wsStore.getImageUrl(fileId);
if (storeUrl) return storeUrl;
// 如果是 IMAGE 类型且有 imageData直接构造 data URL
if (msg.type === 'IMAGE' && msg.imageData) {
const mimeType = msg.mimeType ?? 'image/png';
return msg.imageData.startsWith('data:')
? msg.imageData
: `data:${mimeType};base64,${msg.imageData}`;
}
return undefined;
}
async function handleFileSelected(files: File[]) {
for (const file of files) {
const result = await wsStore.sendFile(roomCode, file);
if (!result.ok && result.error) {
showDownloadToast(result.error);
}
}
}
/**
* 下载图片
*/
function handleImageDownload(msg: RoomMessagePayload) {
const fileId = msg.fileId ?? '';
const fileName = msg.fileName ?? 'image.png';
const imageSrc = getImageSrc(msg);
if (!imageSrc) {
const isMe = msg.senderId === wsStore.myUserId;
showDownloadToast(
isMe
? '您发送的图片请从本机获取'
: '无法下载:图片未就绪或来自历史记录',
);
return;
}
// 创建下载链接
const a = document.createElement('a');
a.href = imageSrc;
a.download = fileName;
a.style.display = 'none';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
}
async function handleFileDownload(msg: RoomMessagePayload) {
const fileId = msg.fileId ?? '';
if (!fileId) {
showDownloadToast('无法下载:文件信息不完整');
return;
}
const fileName = msg.fileName ?? 'download';
// 服务器存储文件:走 HTTP 下载,带进度
if (wsStore.isServerFile(msg)) {
const url = getFileDownloadUrl(roomCode, fileId);
wsStore.setDownloading(fileId, true);
wsStore.setFileProgress(fileId, 0);
try {
const blob = await downloadWithProgress(url, (percent) => {
wsStore.setFileProgress(fileId, percent);
});
wsStore.setFileProgress(fileId, 100);
const blobUrl = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = blobUrl;
a.download = fileName;
a.style.display = 'none';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(() => URL.revokeObjectURL(blobUrl), 500);
} catch {
wsStore.setFileProgress(fileId, 100);
showDownloadToast('下载失败,请重试');
} finally {
wsStore.setDownloading(fileId, false);
}
return;
}
// 兼容:历史或 CHUNK 流文件,从 blob URL 下载
const url = wsStore.getFileBlobUrl(fileId);
if (!url) {
const isMe = msg.senderId === wsStore.myUserId;
showDownloadToast(
isMe
? '您发送的文件请从本机获取,此处不提供下载'
: '无法下载:文件未就绪或来自历史记录(需在当前会话内接收完成)',
);
return;
}
const a = document.createElement('a');
a.href = url;
a.download = fileName;
a.style.display = 'none';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(() => wsStore.revokeFileBlobUrl(fileId), 500);
}
function handleSend(content: string) {
if (!content.trim()) return;
wsStore.sendRoomMessage(roomCode, content);
}
function handleLeave() {
wsStore.leaveRoom(roomCode);
wsStore.disconnect();
router.push({ name: 'home' });
}
function handleClearHistory() {
if (!confirm('确定清空当前房间的本地历史记录吗?')) return;
wsStore.clearRoomHistory(roomCode);
}
function handleExportJson() {
const url = wsStore.exportRoomHistory(roomCode);
const a = document.createElement('a');
a.href = url;
a.download = `DataTool-${roomCode}-${Date.now()}.json`;
a.click();
URL.revokeObjectURL(url);
}
function handleExportText() {
const url = wsStore.exportRoomHistoryAsText(roomCode);
const a = document.createElement('a');
a.href = url;
a.download = `DataTool-${roomCode}-${Date.now()}.txt`;
a.click();
URL.revokeObjectURL(url);
}
</script>

View 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 为 JSONfileId/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);
}
}

View 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
View 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
View 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 },
},
},
});