feat(room): 增加加入令牌及分块传输支持

- 后端Room和MessagePayload新增加入令牌字段,创建房间返回包含令牌
- 新增房间加入令牌验证接口,加入时需提供房间号和令牌
- 前端HomeView新增加入令牌输入框及验证逻辑
- Clipboard工具增加写入API支持及复制按钮
- FileDropZone支持选择文件夹批量上传
- FileMessage和ImageMessage新增分片进度提示及失败重试功能
- API层新增分块上传及断点续传实现,支持大文件分片上传
- 文件上传存储时计算文件sha256,响应中返回该值
- 下载接口支持断点续传,优化大文件下载体验
- README新增加入令牌安全说明及压力测试使用示例
- 资源清理与配置优化,添加磁盘使用水位阈值控制
This commit is contained in:
2026-03-06 03:15:18 +08:00
parent ed07dfaa2e
commit 1c99136944
23 changed files with 1448 additions and 124 deletions

View File

@@ -920,7 +920,6 @@
"integrity": "sha512-WJtwWJu7UdlvzEAUm484QNg5eAoq5QR08KDNx7g45Usrs2NtOPiX8ugDqmKdXkyL03rBqU5dYNYVQetEpBHq2g==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~6.21.0"
}
@@ -1183,7 +1182,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@@ -1815,7 +1813,6 @@
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"jiti": "bin/jiti.js"
}
@@ -2063,7 +2060,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@@ -2556,7 +2552,6 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -2590,7 +2585,6 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -2660,7 +2654,6 @@
"integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.21.3",
"postcss": "^8.4.43",
@@ -2720,7 +2713,6 @@
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.27.tgz",
"integrity": "sha512-aJ/UtoEyFySPBGarREmN4z6qNKpbEguYHMmXSiOGk69czc+zhs0NF6tEFrY8TZKAl8N/LYAkd4JHVd5E/AsSmw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/compiler-dom": "3.5.27",
"@vue/compiler-sfc": "3.5.27",

View File

@@ -8,6 +8,27 @@ export interface UploadFileResponse {
fileName: string;
fileSize: number;
mimeType: string;
sha256?: string;
}
export interface UploadProgressDetail {
mode: 'direct' | 'chunked';
chunkSizeBytes?: number;
totalChunks?: number;
uploadedChunks?: number;
resumedChunks?: number;
retriesUsed?: number;
}
interface InitChunkUploadResponse {
uploadId: string;
uploadedChunks: number[];
totalChunks: number;
}
export interface CreateRoomResponse {
roomCode: string;
joinToken: string;
}
/**
@@ -18,6 +39,21 @@ export async function uploadRoomFile(
roomCode: string,
file: File,
onProgress?: (percent: number) => void,
onDetail?: (detail: UploadProgressDetail) => void,
): Promise<UploadFileResponse> {
const LARGE_FILE_THRESHOLD = 8 * 1024 * 1024;
if (file.size <= LARGE_FILE_THRESHOLD) {
return uploadRoomFileDirect(roomCode, file, onProgress, onDetail);
}
return uploadRoomFileByChunks(roomCode, file, onProgress, onDetail);
}
function uploadRoomFileDirect(
roomCode: string,
file: File,
onProgress?: (percent: number) => void,
onDetail?: (detail: UploadProgressDetail) => void,
): Promise<UploadFileResponse> {
const formData = new FormData();
formData.append('file', file);
@@ -38,6 +74,7 @@ export async function uploadRoomFile(
if (xhr.status >= 200 && xhr.status < 300) {
try {
const data = JSON.parse(xhr.responseText) as UploadFileResponse;
onDetail?.({ mode: 'direct' });
resolve(data);
} catch {
reject(new Error('解析响应失败'));
@@ -61,6 +98,169 @@ export async function uploadRoomFile(
});
}
async function uploadRoomFileByChunks(
roomCode: string,
file: File,
onProgress?: (percent: number) => void,
onDetail?: (detail: UploadProgressDetail) => void,
): Promise<UploadFileResponse> {
const chunkSize = getAdaptiveUploadChunkSize(file.size);
const totalChunks = Math.ceil(file.size / chunkSize);
const uploadId = buildUploadId(file, chunkSize);
const initRes = await fetch(`/api/room/${encodeURIComponent(roomCode)}/file/upload/init`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
uploadId,
fileName: file.name,
fileSize: file.size,
mimeType: file.type || 'application/octet-stream',
totalChunks,
}),
});
if (!initRes.ok) {
throw new Error(`初始化分块上传失败 ${initRes.status}`);
}
const initData = (await initRes.json()) as InitChunkUploadResponse;
const uploaded = new Set<number>(initData.uploadedChunks ?? []);
onDetail?.({
mode: 'chunked',
chunkSizeBytes: chunkSize,
totalChunks,
uploadedChunks: uploaded.size,
resumedChunks: uploaded.size,
});
if (onProgress && totalChunks > 0) {
onProgress(Math.round((uploaded.size / totalChunks) * 100));
}
for (let i = 0; i < totalChunks; i++) {
if (uploaded.has(i)) continue;
const start = i * chunkSize;
const end = Math.min(file.size, start + chunkSize);
const chunkBlob = file.slice(start, end);
const retriesUsed = await uploadChunkWithRetry(roomCode, initData.uploadId, i, chunkBlob, 3);
uploaded.add(i);
onDetail?.({
mode: 'chunked',
chunkSizeBytes: chunkSize,
totalChunks,
uploadedChunks: uploaded.size,
retriesUsed,
});
if (onProgress && totalChunks > 0) {
onProgress(Math.round((uploaded.size / totalChunks) * 100));
}
}
const completeRes = await fetch(`/api/room/${encodeURIComponent(roomCode)}/file/upload/complete`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
uploadId: initData.uploadId,
fileName: file.name,
mimeType: file.type || 'application/octet-stream',
totalChunks,
}),
});
if (!completeRes.ok) {
throw new Error(`分块合并失败 ${completeRes.status}`);
}
return (await completeRes.json()) as UploadFileResponse;
}
async function uploadChunkWithRetry(
roomCode: string,
uploadId: string,
chunkIndex: number,
chunkBlob: Blob,
maxRetries: number,
): Promise<number> {
let lastError: Error | null = null;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
const formData = new FormData();
formData.append('chunk', chunkBlob, `${chunkIndex}.part`);
const res = await fetch(
`/api/room/${encodeURIComponent(roomCode)}/file/upload/chunk?uploadId=${encodeURIComponent(uploadId)}&chunkIndex=${chunkIndex}`,
{
method: 'POST',
body: formData,
},
);
if (!res.ok) {
throw new Error(`分片 ${chunkIndex} 上传失败 ${res.status}`);
}
return attempt;
} catch (err) {
if (err instanceof Error) {
lastError = err;
} else {
lastError = new Error(`分片 ${chunkIndex} 上传失败(网络错误)`);
}
if (attempt < maxRetries) {
await sleep(300 * (attempt + 1));
}
}
}
throw lastError ?? new Error('分片上传失败');
}
function buildUploadId(file: File, chunkSize: number): string {
const source = `${file.name}_${file.size}_${file.lastModified}_${chunkSize}`;
let hash = 2166136261;
for (let index = 0; index < source.length; index++) {
hash ^= source.charCodeAt(index);
hash +=
(hash << 1) +
(hash << 4) +
(hash << 7) +
(hash << 8) +
(hash << 24);
}
return `up_${Math.abs(hash >>> 0).toString(36)}`;
}
function getAdaptiveUploadChunkSize(fileSize: number): number {
const MB = 1024 * 1024;
const connection = getNetworkConnection();
const effectiveType = connection?.effectiveType ?? '';
const saveData = connection?.saveData ?? false;
if (saveData || effectiveType === 'slow-2g' || effectiveType === '2g') {
return 512 * 1024;
}
if (effectiveType === '3g') {
return MB;
}
if (fileSize >= 1024 * MB) {
return 4 * MB;
}
if (fileSize >= 256 * MB) {
return 2 * MB;
}
return 1536 * 1024;
}
function getNetworkConnection():
| { effectiveType?: string; saveData?: boolean }
| undefined {
const nav = navigator as Navigator & {
connection?: { effectiveType?: string; saveData?: boolean };
mozConnection?: { effectiveType?: string; saveData?: boolean };
webkitConnection?: { effectiveType?: string; saveData?: boolean };
};
return nav.connection ?? nav.mozConnection ?? nav.webkitConnection;
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/**
* 获取当前客户端的 IP由服务端从请求中解析用于作为默认昵称。
*/
@@ -72,6 +272,24 @@ export async function getMyIp(apiBase = ''): Promise<string> {
return data.ip ?? '';
}
export async function createRoom(apiBase = ''): Promise<CreateRoomResponse> {
const url = `${apiBase}/api/room/create`.replace(/\/+/g, '/');
const res = await fetch(url, { method: 'POST' });
if (!res.ok) throw new Error(`创建房间失败: ${res.status}`);
return (await res.json()) as CreateRoomResponse;
}
export async function verifyRoomAccess(
roomCode: string,
joinToken: string,
apiBase = '',
): Promise<boolean> {
const params = new URLSearchParams({ roomCode, joinToken });
const url = `${apiBase}/api/room/verify?${params.toString()}`.replace(/\/+/g, '/');
const res = await fetch(url);
return res.ok;
}
/**
* 返回房间内文件的下载 URL相对路径走当前 origin 的 /api 代理)。
*/
@@ -86,26 +304,83 @@ 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();
});
return downloadWithResume(url, onProgress, 2);
}
async function downloadWithResume(
url: string,
onProgress?: (percent: number) => void,
maxRetries = 2,
): Promise<Blob> {
let downloaded = 0;
let totalSize: number | null = null;
const chunks: Uint8Array[] = [];
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
const requestUrl = appendOffset(url, downloaded > 0 ? downloaded : null);
const response = await fetch(requestUrl);
if (!response.ok) {
throw new Error(response.status === 404 ? '文件不存在' : `下载失败 ${response.status}`);
}
const contentRange = response.headers.get('Content-Range');
const contentLength = Number(response.headers.get('Content-Length') ?? '0');
if (downloaded > 0 && response.status === 200) {
downloaded = 0;
totalSize = null;
chunks.length = 0;
}
if (totalSize == null) {
if (contentRange) {
const match = contentRange.match(/\/([0-9]+)$/);
if (match) {
totalSize = Number(match[1]);
}
}
if (totalSize == null && contentLength > 0) {
totalSize = downloaded + contentLength;
}
}
if (!response.body) {
throw new Error('下载流不可用');
}
const reader = response.body.getReader();
while (true) {
const { done, value } = await reader.read();
if (done) break;
if (!value) continue;
chunks.push(value);
downloaded += value.length;
if (onProgress && totalSize && totalSize > 0) {
onProgress(Math.min(100, Math.round((downloaded / totalSize) * 100)));
}
}
const blob = new Blob(chunks, {
type: response.headers.get('Content-Type') ?? 'application/octet-stream',
});
if (onProgress) onProgress(100);
return blob;
} catch (err) {
if (attempt >= maxRetries) {
if (err instanceof Error) throw err;
throw new Error('网络错误');
}
}
}
throw new Error('下载失败');
}
function appendOffset(url: string, offset: number | null): string {
if (offset == null || offset <= 0) return url;
const hasQuery = url.includes('?');
const sep = hasQuery ? '&' : '?';
return `${url}${sep}offset=${offset}`;
}

View File

@@ -22,6 +22,15 @@
multiple
@change="handleInputChange"
/>
<input
ref="folderInputRef"
type="file"
class="sr-only"
multiple
webkitdirectory
directory
@change="handleFolderInputChange"
/>
<span class="inline-flex items-center gap-1.5 text-[11px] text-slate-500">
<svg
xmlns="http://www.w3.org/2000/svg"
@@ -45,6 +54,14 @@
<!-- 读取剪贴板按钮 -->
<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"
title="选择整个文件夹并批量发送"
@click="triggerFolderInput"
>
选择文件夹
</button>
<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"
@@ -93,6 +110,7 @@ const emit = defineEmits<{
const zoneRef = ref<HTMLElement | null>(null);
const inputRef = ref<HTMLInputElement | null>(null);
const folderInputRef = ref<HTMLInputElement | null>(null);
const isDragOver = ref(false);
// 剪贴板读取状态
@@ -130,6 +148,10 @@ function triggerInput() {
inputRef.value?.click();
}
function triggerFolderInput() {
folderInputRef.value?.click();
}
function emitFiles(files: FileList | File[]) {
const list = Array.from(files).filter((f) => f && f.size >= 0);
if (list.length) emit('fileSelected', list);
@@ -147,6 +169,15 @@ function handleInputChange(e: Event) {
input.value = '';
}
function handleFolderInputChange(e: Event) {
const input = e.target as HTMLInputElement;
if (input.files?.length) {
emitFiles(input.files);
showClipboardMessage(`已选择 ${input.files.length} 个文件,正在排队发送`, 'success');
}
input.value = '';
}
/**
* 处理粘贴事件
* - 优先处理文件(如截图、复制的文件)

View File

@@ -74,9 +74,11 @@
</BaseButton>
</div>
<div v-else-if="status === 'error'" class="mt-2">
<p class="text-xs text-danger">传输失败</p>
<p class="text-xs text-danger">
传输失败<span v-if="chunkHint"> · {{ chunkHint }}</span>
</p>
<BaseButton size="sm" variant="ghost" class="mt-1" @click="$emit('retry')">
重试
重试失败分片
</BaseButton>
</div>
</div>
@@ -96,6 +98,7 @@ const props = withDefaults(
isMe?: boolean;
status?: 'sending' | 'receiving' | 'done' | 'error';
progress?: number; // 0100
chunkHint?: string;
}>(),
{
fileSize: 0,
@@ -130,8 +133,16 @@ const showProgress = computed(
);
const statusLabel = computed(() => {
if (props.status === 'sending') return `发送中 ${props.progress}%`;
if (props.status === 'receiving') return `接收中 ${props.progress}%`;
if (props.status === 'sending') {
return props.chunkHint
? `发送中 ${props.progress}% · ${props.chunkHint}`
: `发送中 ${props.progress}%`;
}
if (props.status === 'receiving') {
return props.chunkHint
? `接收中 ${props.progress}% · ${props.chunkHint}`
: `接收中 ${props.progress}%`;
}
return '';
});

View File

@@ -46,6 +46,7 @@
<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 }}%
<span v-if="chunkHint"> · {{ chunkHint }}</span>
</p>
<div class="mt-2 h-1.5 w-full max-w-[160px] overflow-hidden rounded-full bg-slate-200">
<div
@@ -55,6 +56,22 @@
</div>
</div>
<div
v-if="status === 'error'"
class="mt-2 rounded-md border border-red-200 bg-red-50 px-3 py-2"
>
<p class="text-xs text-danger">
传输失败<span v-if="chunkHint"> · {{ chunkHint }}</span>
</p>
<button
type="button"
class="mt-1 inline-flex items-center rounded border border-red-200 bg-white px-2 py-1 text-xs text-red-600 transition-colors hover:bg-red-50"
@click="handleRetry"
>
重试失败分片
</button>
</div>
<!-- 图片显示 -->
<div
v-else-if="imageSrc"
@@ -202,6 +219,8 @@ const props = withDefaults(
status?: 'sending' | 'receiving' | 'done' | 'error';
/** 进度 0-100 */
progress?: number;
/** 分片进度提示,例如 3/20 分片 */
chunkHint?: string;
}>(),
{
isMe: false,
@@ -212,6 +231,7 @@ const props = withDefaults(
const emit = defineEmits<{
download: [];
retry: [];
}>();
const showPreview = ref(false);
@@ -255,6 +275,10 @@ function handleDownload() {
emit('download');
}
function handleRetry() {
emit('retry');
}
// ESC 键关闭预览
if (typeof window !== 'undefined') {
window.addEventListener('keydown', (e) => {

View File

@@ -28,6 +28,19 @@
>
{{ shortDisplayName }}
</span>
<button
type="button"
class="text-[11px] rounded px-1.5 py-0.5 border transition-colors"
:class="
isMe
? 'border-white/25 text-white/80 hover:bg-white/10'
: 'border-slate-200 text-slate-500 hover:bg-slate-100'
"
:aria-label="copied ? '已复制消息' : '复制消息内容'"
@click="copyMessage"
>
{{ copied ? '已复制' : '复制' }}
</button>
<span
v-if="timestamp"
class="text-[11px] ml-auto shrink-0 tabular-nums"
@@ -47,8 +60,9 @@
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { computed, onBeforeUnmount, ref } from 'vue';
import { getAvatarLetter, getAvatarColorClass, getShortDisplayName } from '@/utils/avatar';
import { writeClipboardText } from '@/utils/clipboard';
const props = withDefaults(
defineProps<{
@@ -71,6 +85,26 @@ const displayName = computed(() => {
const shortDisplayName = computed(() => getShortDisplayName(displayName.value));
const avatarLetter = computed(() => getAvatarLetter(displayName.value));
const avatarColorClass = computed(() => getAvatarColorClass(displayName.value));
const copied = ref(false);
let copiedTimer: ReturnType<typeof setTimeout> | null = null;
onBeforeUnmount(() => {
if (copiedTimer) {
clearTimeout(copiedTimer);
copiedTimer = null;
}
});
async function copyMessage(): Promise<void> {
const result = await writeClipboardText(props.content ?? '');
if (!result.success) return;
copied.value = true;
if (copiedTimer) clearTimeout(copiedTimer);
copiedTimer = setTimeout(() => {
copied.value = false;
copiedTimer = null;
}, 1500);
}
function formatTime(ts: number): string {
const d = new Date(ts);

View File

@@ -18,10 +18,21 @@
复制
</BaseButton>
</div>
<p class="mt-1 text-xs text-slate-600">
加入令牌 {{ joinToken || '--------' }}
</p>
<div class="mt-2 flex items-center gap-2">
<StatusDot :status="connectionStatusDot" />
<span class="text-xs text-slate-600">{{ connectionStatusLabel }}</span>
</div>
<p
v-if="copyHint"
class="mt-1 text-xs"
:class="copyOk ? 'text-success' : 'text-danger'"
role="status"
>
{{ copyHint }}
</p>
<p class="mt-1 text-xs text-slate-500">临时房间 · 数据不落库</p>
</div>
@@ -49,6 +60,29 @@
</BaseButton>
</div>
</section>
<section>
<span class="text-xs font-medium text-slate-700 block mb-2">
传输状态
</span>
<div class="grid grid-cols-3 gap-2 text-center">
<div class="rounded border border-slate-200 bg-slate-50 px-2 py-2">
<p class="text-[11px] text-slate-500">消息</p>
<p class="text-sm font-semibold text-slate-800">{{ transferStats.messageCount }}</p>
</div>
<div class="rounded border border-slate-200 bg-slate-50 px-2 py-2">
<p class="text-[11px] text-slate-500">发送中</p>
<p class="text-sm font-semibold text-primary">{{ transferStats.sendingCount }}</p>
</div>
<div class="rounded border border-slate-200 bg-slate-50 px-2 py-2">
<p class="text-[11px] text-slate-500">接收中</p>
<p class="text-sm font-semibold text-warning">{{ transferStats.receivingCount }}</p>
</div>
</div>
<p class="mt-2 text-[11px] text-slate-500">
队列 {{ transferStats.queueCount }} · 续传命中 {{ transferStats.resumedCount }} · 上传重试 {{ transferStats.retryCount }}
</p>
</section>
</div>
<div class="p-4 border-t border-slate-200">
@@ -65,22 +99,40 @@
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { computed, onBeforeUnmount, ref } 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';
import { writeClipboardText } from '@/utils/clipboard';
const props = withDefaults(
defineProps<{
roomCode: string;
joinToken: string;
userList: SessionInfo[];
connectionStatus: ConnectionStatus;
myUserId: string;
transferStats: {
messageCount: number;
sendingCount: number;
receivingCount: number;
queueCount: number;
resumedCount: number;
retryCount: number;
};
}>(),
{
userList: () => [],
transferStats: () => ({
messageCount: 0,
sendingCount: 0,
receivingCount: 0,
queueCount: 0,
resumedCount: 0,
retryCount: 0,
}),
},
);
@@ -91,6 +143,10 @@ defineEmits<{
exportText: [];
}>();
const copyHint = ref('');
const copyOk = ref(true);
let copyHintTimer: ReturnType<typeof setTimeout> | null = null;
const connectionStatusDot = computed(() => {
switch (props.connectionStatus) {
case 'connecting':
@@ -119,13 +175,30 @@ const connectionStatusLabel = computed(() => {
}
});
function copyRoomCode() {
if (!props.roomCode) return;
navigator.clipboard
?.writeText(props.roomCode)
.then(() => {
// 可接入 Toast 提示“已复制”
})
.catch(() => {});
onBeforeUnmount(() => {
if (copyHintTimer) {
clearTimeout(copyHintTimer);
copyHintTimer = null;
}
});
function showCopyHint(message: string, ok: boolean): void {
copyHint.value = message;
copyOk.value = ok;
if (copyHintTimer) clearTimeout(copyHintTimer);
copyHintTimer = setTimeout(() => {
copyHint.value = '';
copyHintTimer = null;
}, 1800);
}
async function copyRoomCode() {
if (!props.roomCode || !props.joinToken) return;
const result = await writeClipboardText(`${props.roomCode}-${props.joinToken}`);
if (result.success) {
showCopyHint('房间邀请码已复制', true);
return;
}
showCopyHint(result.error ?? '复制失败,请手动复制', false);
}
</script>

View File

@@ -15,14 +15,23 @@ const MAX_HISTORY_ITEMS = 500;
const MAX_HISTORY_AGE_MS = 7 * 24 * 60 * 60 * 1000;
/** 超过此长度则分片发送(与 doc05 一致,如 32KB */
const CHUNK_THRESHOLD = 32 * 1024;
const CHUNK_SIZE = 32 * 1024;
const CHUNK_SIZE_SMALL = 32 * 1024;
const CHUNK_SIZE_MEDIUM = 64 * 1024;
const CHUNK_SIZE_LARGE = 96 * 1024;
/** 分片缓存超时ms超时未收齐则清理并可选提示 */
const CHUNK_CACHE_TTL = 60 * 1000;
/** 单文件大小上限2GB与后端 transfer.max-file-size 一致 */
const MAX_FILE_SIZE = 2 * 1024 * 1024 * 1024;
/** 接收端背压:文件分片缓存的最大并发文件数 */
const MAX_ACTIVE_FILE_CHUNK_ENTRIES = 12;
/** 接收端背压:文本分片缓存的最大并发消息数 */
const MAX_ACTIVE_TEXT_CHUNK_ENTRIES = 200;
/** 发送端队列上限,避免一次性堆积过多任务 */
const MAX_SEND_QUEUE_ITEMS = 50;
interface JoinRoomPayload {
roomCode: string;
joinToken?: string;
senderId?: string;
nickname?: string;
}
@@ -62,6 +71,19 @@ function saveHistory(roomCode: string, messages: RoomMessagePayload[]): void {
}
}
function getAdaptiveChunkSize(contentLength: number, status: ConnectionStatus): number {
if (status === 'reconnecting') {
return CHUNK_SIZE_SMALL;
}
if (contentLength >= 1024 * 1024) {
return CHUNK_SIZE_LARGE;
}
if (contentLength >= 256 * 1024) {
return CHUNK_SIZE_MEDIUM;
}
return CHUNK_SIZE_SMALL;
}
export const useWsStore = defineStore('ws', () => {
const status = ref<ConnectionStatus>('disconnected');
const client = ref<RoomWsClient | null>(null);
@@ -76,6 +98,11 @@ export const useWsStore = defineStore('ws', () => {
const downloadingFileIds = ref<Record<string, boolean>>({});
/** 文件发送进度 fileId -> 0-100 */
const sendingProgress = ref<Record<string, number>>({});
const uploadChunkStats = ref<Record<string, { uploaded: number; total: number }>>({});
const uploadRetryCount = ref(0);
const uploadResumeCount = ref(0);
const uploadQueueSize = ref(0);
const failedUploads = ref<Record<string, { roomCode: string; file: File; reason?: string; failedChunkIndex?: number }>>({});
/** 当前正在发送的 fileId用于收到自己发的 FILE 时不重复追加 */
const sendingFileIds = new Set<string>();
/** 接收完成后 fileId -> object URL用于下载 */
@@ -110,6 +137,23 @@ export const useWsStore = defineStore('ws', () => {
receivedAt: number;
}
>();
const fileSendQueue: Array<{
roomCode: string;
file: File;
resolve: (result: { ok: boolean; error?: string }) => void;
}> = [];
const sendingQueueBusy = ref(false);
function pushSystemTip(message: string): void {
if (!currentRoomCode.value) return;
roomMessages.value.push({
roomCode: currentRoomCode.value,
type: 'SYSTEM',
senderId: 'system',
data: { message },
});
saveHistory(currentRoomCode.value, roomMessages.value);
}
function init(baseUrl: string, endpoint = '/ws') {
if (!client.value) {
@@ -135,6 +179,11 @@ export const useWsStore = defineStore('ws', () => {
fileChunkCache.clear();
sendingFileIds.clear();
sendingProgress.value = {};
uploadChunkStats.value = {};
fileSendQueue.length = 0;
uploadQueueSize.value = 0;
failedUploads.value = {};
sendingQueueBusy.value = false;
client.value?.disconnect();
status.value = 'disconnected';
currentRoomCode.value = null;
@@ -176,6 +225,10 @@ export const useWsStore = defineStore('ws', () => {
const messageId = payload.messageId;
let entry = textChunkCache.get(messageId);
if (!entry) {
if (textChunkCache.size >= MAX_ACTIVE_TEXT_CHUNK_ENTRIES) {
pushSystemTip('接收文本分片过多,已启用限流,请稍后重试');
return;
}
entry = {
chunks: new Map(),
totalChunks: payload.totalChunks,
@@ -214,6 +267,10 @@ export const useWsStore = defineStore('ws', () => {
payload.totalChunks != null &&
payload.storage !== 'server'
) {
if (!fileChunkCache.has(fileId) && fileChunkCache.size >= MAX_ACTIVE_FILE_CHUNK_ENTRIES) {
pushSystemTip('接收文件任务过多,已启用限流,请稍后重试');
return;
}
fileChunkCache.set(fileId, {
meta: {
fileName: payload.fileName ?? '未命名',
@@ -353,6 +410,7 @@ export const useWsStore = defineStore('ws', () => {
roomCode: payload.roomCode,
type: 'SYSTEM',
senderId,
joinToken: payload.joinToken,
content: nickname,
},
handleRoomMessage,
@@ -376,6 +434,11 @@ export const useWsStore = defineStore('ws', () => {
fileChunkCache.clear();
sendingFileIds.clear();
sendingProgress.value = {};
uploadChunkStats.value = {};
fileSendQueue.length = 0;
uploadQueueSize.value = 0;
failedUploads.value = {};
sendingQueueBusy.value = false;
currentRoomCode.value = null;
userList.value = [];
roomMessages.value = [];
@@ -394,11 +457,13 @@ export const useWsStore = defineStore('ws', () => {
});
return;
}
const chunkSize = getAdaptiveChunkSize(content.length, status.value);
const messageId = `${myUserId.value}_${Date.now()}`;
const totalChunks = Math.ceil(content.length / CHUNK_SIZE);
const totalChunks = Math.ceil(content.length / chunkSize);
for (let i = 0; i < totalChunks; i++) {
const start = i * CHUNK_SIZE;
const chunk = content.slice(start, start + CHUNK_SIZE);
const start = i * chunkSize;
const chunk = content.slice(start, start + chunkSize);
client.value.sendMessage(roomCode, {
roomCode,
type: 'TEXT',
@@ -453,7 +518,40 @@ export const useWsStore = defineStore('ws', () => {
return URL.createObjectURL(new Blob([text], { type: 'text/plain;charset=utf-8' }));
}
async function sendFile(
async function processFileSendQueue(): Promise<void> {
if (sendingQueueBusy.value) return;
if (fileSendQueue.length === 0) return;
const task = fileSendQueue.shift();
uploadQueueSize.value = fileSendQueue.length;
if (!task) return;
sendingQueueBusy.value = true;
try {
const result = await sendFileInternal(task.roomCode, task.file);
task.resolve(result);
} finally {
sendingQueueBusy.value = false;
if (fileSendQueue.length > 0) {
void processFileSendQueue();
}
}
}
function sendFile(
roomCode: string,
file: File,
): Promise<{ ok: boolean; error?: string }> {
return new Promise((resolve) => {
if (fileSendQueue.length >= MAX_SEND_QUEUE_ITEMS) {
resolve({ ok: false, error: '发送队列已满,请稍后再试' });
return;
}
fileSendQueue.push({ roomCode, file, resolve });
uploadQueueSize.value = fileSendQueue.length;
void processFileSendQueue();
});
}
async function sendFileInternal(
roomCode: string,
file: File,
): Promise<{ ok: boolean; error?: string }> {
@@ -493,15 +591,36 @@ export const useWsStore = defineStore('ws', () => {
}
try {
let resumeReported = false;
const res = await uploadRoomFile(roomCode, file, (percent) => {
sendingProgress.value[tempFileId] = percent;
sendingProgress.value = { ...sendingProgress.value };
}, (detail) => {
if (detail.mode === 'chunked' && detail.totalChunks && detail.uploadedChunks != null) {
uploadChunkStats.value[tempFileId] = {
uploaded: detail.uploadedChunks,
total: detail.totalChunks,
};
uploadChunkStats.value = { ...uploadChunkStats.value };
}
if (detail.retriesUsed && detail.retriesUsed > 0) {
uploadRetryCount.value += detail.retriesUsed;
}
if (!resumeReported && (detail.resumedChunks ?? 0) > 0) {
resumeReported = true;
uploadResumeCount.value += 1;
pushSystemTip(`文件「${file.name}」命中断点续传,已复用 ${detail.resumedChunks} 个分片`);
}
});
const actualFileId = res.fileId;
sendingFileIds.delete(tempFileId);
sendingFileIds.add(actualFileId);
sendingProgress.value[actualFileId] = 100;
delete sendingProgress.value[tempFileId];
delete uploadChunkStats.value[tempFileId];
uploadChunkStats.value = { ...uploadChunkStats.value };
delete failedUploads.value[tempFileId];
failedUploads.value = { ...failedUploads.value };
const payload: RoomMessagePayload = {
...optimistic,
@@ -509,6 +628,7 @@ export const useWsStore = defineStore('ws', () => {
fileName: res.fileName,
fileSize: res.fileSize,
mimeType: res.mimeType,
sha256: res.sha256,
storage: 'server',
};
const idx = roomMessages.value.findIndex(
@@ -532,17 +652,16 @@ export const useWsStore = defineStore('ws', () => {
} 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 : '发送失败,请重试';
const failedChunkIndex = extractFailedChunkIndex(errMsg);
failedUploads.value[tempFileId] = {
roomCode,
file,
reason: errMsg,
failedChunkIndex,
};
failedUploads.value = { ...failedUploads.value };
pushSystemTip(`文件「${file.name}」上传失败,可点击重试继续上传`);
return { ok: false, error: errMsg };
}
}
@@ -553,6 +672,58 @@ export const useWsStore = defineStore('ws', () => {
return fileProgress.value[fileId] ?? 0;
}
function getUploadChunkHint(fileId: string): string | null {
const failed = failedUploads.value[fileId];
if (failed) {
if (failed.failedChunkIndex != null) {
return `失败分片 #${failed.failedChunkIndex}`;
}
return '上传失败';
}
const stat = uploadChunkStats.value[fileId];
if (!stat || stat.total <= 0) return null;
return `${stat.uploaded}/${stat.total} 分片`;
}
function isFailedUpload(fileId: string): boolean {
return !!failedUploads.value[fileId];
}
async function retryFailedUpload(fileId: string): Promise<{ ok: boolean; error?: string }> {
const failed = failedUploads.value[fileId];
if (!failed) {
return { ok: false, error: '未找到失败上传任务' };
}
const roomCode = failed.roomCode;
const file = failed.file;
const idx = roomMessages.value.findIndex(
(m) => m.type === 'FILE' && m.fileId === fileId && m.senderId === myUserId.value,
);
if (idx !== -1) {
roomMessages.value.splice(idx, 1);
}
delete failedUploads.value[fileId];
failedUploads.value = { ...failedUploads.value };
delete sendingProgress.value[fileId];
delete uploadChunkStats.value[fileId];
uploadChunkStats.value = { ...uploadChunkStats.value };
const url = imageBlobUrls.value[fileId];
if (url && url.startsWith('blob:')) {
URL.revokeObjectURL(url);
}
delete imageBlobUrls.value[fileId];
const result = await sendFile(roomCode, file);
return result;
}
function extractFailedChunkIndex(errorMessage: string): number | undefined {
const match = errorMessage.match(/分片\s*(\d+)/);
if (!match) return undefined;
const value = Number(match[1]);
return Number.isFinite(value) ? value : undefined;
}
/** 设置文件进度(用于下载时在 UI 显示 接收中 X% */
function setFileProgress(fileId: string, percent: number): void {
fileProgress.value[fileId] = percent;
@@ -674,6 +845,9 @@ export const useWsStore = defineStore('ws', () => {
userList,
roomMessages,
fileProgress,
uploadRetryCount,
uploadResumeCount,
uploadQueueSize,
init,
connect,
disconnect,
@@ -685,6 +859,9 @@ export const useWsStore = defineStore('ws', () => {
exportRoomHistory,
exportRoomHistoryAsText,
getFileProgress,
getUploadChunkHint,
isFailedUpload,
retryFailedUpload,
setFileProgress,
setDownloading,
isDownloading,

View File

@@ -21,6 +21,7 @@ export interface RoomMessagePayload {
roomCode?: string;
senderId?: string;
senderName?: string;
joinToken?: string;
timestamp?: number;
/** 文本内容TEXT */
content?: string;
@@ -35,6 +36,7 @@ export interface RoomMessagePayload {
fileName?: string;
fileSize?: number;
mimeType?: string;
sha256?: string;
chunkIndex?: number;
/** 分片总数;无此字段或 storage===server 表示服务器存储,下载走 HTTP */
totalChunks?: number;

View File

@@ -9,6 +9,11 @@ export interface ClipboardReadResult {
error?: string;
}
export interface ClipboardWriteResult {
success: boolean;
error?: string;
}
/**
* 检查剪贴板 API 是否可用
*/
@@ -20,6 +25,17 @@ export function isClipboardApiAvailable(): boolean {
);
}
/**
* 检查剪贴板写入 API 是否可用
*/
export function isClipboardWriteAvailable(): boolean {
return (
typeof navigator !== 'undefined' &&
typeof navigator.clipboard !== 'undefined' &&
typeof navigator.clipboard.writeText === 'function'
);
}
/**
* 检查是否在安全上下文中HTTPS 或 localhost
*/
@@ -105,6 +121,61 @@ export async function readClipboardText(): Promise<ClipboardReadResult> {
}
}
/**
* 写入文本到剪贴板。
* 优先使用 Clipboard API失败时降级到 execCommand('copy')。
*/
export async function writeClipboardText(text: string): Promise<ClipboardWriteResult> {
if (!text) {
return {
success: false,
error: '复制内容为空',
};
}
if (isSecureContext() && isClipboardWriteAvailable()) {
try {
await navigator.clipboard.writeText(text);
return { success: true };
} catch {
// fallback to legacy way
}
}
if (typeof document === 'undefined') {
return {
success: false,
error: '当前环境不支持复制',
};
}
try {
const textarea = document.createElement('textarea');
textarea.value = text;
textarea.setAttribute('readonly', '');
textarea.style.position = 'fixed';
textarea.style.left = '-9999px';
textarea.style.top = '0';
document.body.appendChild(textarea);
textarea.select();
textarea.setSelectionRange(0, textarea.value.length);
const ok = document.execCommand('copy');
document.body.removeChild(textarea);
if (ok) {
return { success: true };
}
return {
success: false,
error: '复制失败,请手动复制',
};
} catch {
return {
success: false,
error: '复制失败,请手动复制',
};
}
}
/**
* 从粘贴事件中提取文件列表
*/

View File

@@ -37,7 +37,7 @@
<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">
@@ -58,6 +58,20 @@
{{ joinError }}
</p>
</div>
<div class="space-y-1.5">
<label class="block text-xs font-medium text-slate-700">
加入令牌8
</label>
<input
v-model="joinToken"
type="text"
maxlength="8"
class="dt-input"
:class="{ 'border-danger focus:border-danger': joinError }"
placeholder="例如 AB12CD34"
@input="handleJoinTokenInput"
/>
</div>
<BaseButton
type="submit"
size="lg"
@@ -76,8 +90,8 @@
<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';
import { createRoom, verifyRoomAccess } from '@/api/room';
// 开发时用空字符串走同源,由 Vite 代理到后端,便于其它设备通过本机 IP:5173 访问
const API_BASE = import.meta.env.VITE_API_BASE ?? '';
@@ -88,21 +102,23 @@ const createRoomCodeDisplay = ref('------');
const createLoading = ref(false);
const createError = ref('');
const joinRoomCode = ref('');
const joinToken = ref('');
const joinError = ref('');
const isJoinValid = computed(() => /^[0-9]{6}$/.test(joinRoomCode.value));
const isJoinValid = computed(() => {
return /^[0-9]{6}$/.test(joinRoomCode.value) && /^[A-Z0-9]{8}$/.test(joinToken.value);
});
async function handleCreateAndEnter() {
createError.value = '';
createLoading.value = true;
try {
const { data } = await axios.post<{ roomCode: string }>(
`${API_BASE}/api/room/create`,
);
const data = await createRoom(API_BASE);
const roomCode = data.roomCode;
const token = data.joinToken;
createRoomCodeDisplay.value = roomCode;
createLoading.value = false;
router.push({ name: 'room', params: { roomCode } });
router.push({ name: 'room', params: { roomCode }, query: { token } });
} catch {
createError.value = '创建房间失败,请稍后重试。';
createRoomCodeDisplay.value = String(
@@ -112,13 +128,26 @@ async function handleCreateAndEnter() {
}
}
function handleJoin() {
function handleJoinTokenInput() {
joinToken.value = joinToken.value.toUpperCase().replace(/[^A-Z0-9]/g, '');
joinError.value = '';
}
async function handleJoin() {
joinError.value = '';
if (!isJoinValid.value) {
joinError.value = '房间号必须是 6 位数字。';
joinError.value = '房间号 6 位数字,加入令牌为 8 位字母数字。';
return;
}
router.push({ name: 'room', params: { roomCode: joinRoomCode.value } });
const ok = await verifyRoomAccess(joinRoomCode.value, joinToken.value, API_BASE);
if (!ok) {
joinError.value = '房间号或加入令牌错误。';
return;
}
router.push({
name: 'room',
params: { roomCode: joinRoomCode.value },
query: { token: joinToken.value },
});
}
</script>

View File

@@ -14,9 +14,11 @@
<div class="hidden lg:flex lg:w-80 lg:shrink-0">
<RoomPanel
:room-code="roomCode"
:join-token="joinToken"
:user-list="wsStore.userList"
:connection-status="wsStore.status"
:my-user-id="wsStore.myUserId"
:transfer-stats="transferStats"
@leave="handleLeave"
@clear-history="handleClearHistory"
@export-json="handleExportJson"
@@ -52,7 +54,9 @@
:is-me="msg.senderId === wsStore.myUserId"
:status="getFileStatus(msg)"
:progress="wsStore.getFileProgress(msg.fileId ?? '')"
:chunk-hint="wsStore.getUploadChunkHint(msg.fileId ?? '')"
@download="handleImageDownload(msg)"
@retry="handleRetryUpload(msg)"
/>
<!-- 普通文件消息 -->
<FileMessage
@@ -63,7 +67,9 @@
:is-me="msg.senderId === wsStore.myUserId"
:status="getFileStatus(msg)"
:progress="wsStore.getFileProgress(msg.fileId ?? '')"
:chunk-hint="wsStore.getUploadChunkHint(msg.fileId ?? '')"
@download="handleFileDownload(msg)"
@retry="handleRetryUpload(msg)"
/>
</template>
</div>
@@ -86,7 +92,7 @@
</template>
<script setup lang="ts">
import { ref, watch, onMounted } from 'vue';
import { computed, ref, watch, onMounted } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import BaseButton from '@/components/ui/BaseButton.vue';
import RoomPanel from '@/components/RoomPanel.vue';
@@ -99,6 +105,7 @@ import { getFileDownloadUrl, downloadWithProgress } from '@/api/room';
import { useWsStore } from '@/stores/wsStore';
import type { RoomMessagePayload } from '@/types/room';
import { isImageMimeType } from '@/types/room';
import { sha256HexOfBlob } from '@/utils/hash';
// 未设置 VITE_API_BASE 时使用同源:开发时走 Vite 代理Docker/生产时与页面同 host:port避免硬编码 localhost 导致部署后连不上
const API_BASE = import.meta.env.VITE_API_BASE ?? '';
@@ -106,12 +113,41 @@ const API_BASE = import.meta.env.VITE_API_BASE ?? '';
const route = useRoute();
const router = useRouter();
const roomCode = route.params.roomCode as string;
const joinToken = computed(() => {
const token = route.query.token;
return typeof token === 'string' ? token : '';
});
const messageListRef = ref<HTMLElement | null>(null);
const wsStore = useWsStore();
const downloadToast = ref('');
let downloadToastTimer: ReturnType<typeof setTimeout> | null = null;
const transferStats = computed(() => {
let sendingCount = 0;
let receivingCount = 0;
for (const msg of wsStore.roomMessages) {
if (msg.type !== 'FILE' && msg.type !== 'IMAGE') continue;
const fileId = msg.fileId ?? '';
if (!fileId) continue;
const progress = wsStore.getFileProgress(fileId);
if (progress < 0 || progress >= 100) continue;
if (msg.senderId === wsStore.myUserId) {
sendingCount += 1;
} else if (wsStore.isDownloading(fileId)) {
receivingCount += 1;
}
}
return {
messageCount: wsStore.roomMessages.length,
sendingCount,
receivingCount,
queueCount: wsStore.uploadQueueSize,
resumedCount: wsStore.uploadResumeCount,
retryCount: wsStore.uploadRetryCount,
};
});
function showDownloadToast(message: string) {
downloadToast.value = message;
if (downloadToastTimer) clearTimeout(downloadToastTimer);
@@ -130,8 +166,12 @@ watch(
() => wsStore.status,
(status) => {
if (status === 'connected' && roomCode) {
if (!joinToken.value) {
showDownloadToast('缺少加入令牌,请返回首页重新加入房间');
return;
}
// 使用 store 中已生成的随机中国人名
wsStore.joinRoom({ roomCode, nickname: wsStore.myNickname });
wsStore.joinRoom({ roomCode, joinToken: joinToken.value, nickname: wsStore.myNickname });
}
},
{ immediate: true },
@@ -160,6 +200,9 @@ function resolveSenderName(senderId?: string): string {
function getFileStatus(msg: RoomMessagePayload): 'sending' | 'receiving' | 'done' | 'error' {
const fileId = msg.fileId ?? '';
if (msg.senderId === wsStore.myUserId && wsStore.isFailedUpload(fileId)) {
return 'error';
}
const progress = wsStore.getFileProgress(fileId);
const isMe = msg.senderId === wsStore.myUserId;
// 发送方099 显示「发送中」
@@ -169,6 +212,18 @@ function getFileStatus(msg: RoomMessagePayload): 'sending' | 'receiving' | 'done
return 'done';
}
async function handleRetryUpload(msg: RoomMessagePayload) {
const fileId = msg.fileId ?? '';
if (!fileId) {
showDownloadToast('重试失败:文件标识缺失');
return;
}
const result = await wsStore.retryFailedUpload(fileId);
if (!result.ok && result.error) {
showDownloadToast(result.error);
}
}
/**
* 判断消息是否为图片类型
* - FILE 类型且 mimeType 为 image/*(图片统一走 HTTP与文件一致
@@ -203,6 +258,25 @@ function getImageSrc(msg: RoomMessagePayload): string | undefined {
return undefined;
}
async function verifyDownloadedBlob(
msg: RoomMessagePayload,
blob: Blob,
): Promise<boolean> {
const expected = (msg.sha256 ?? '').trim().toLowerCase();
if (!expected) return true;
const actual = await sha256HexOfBlob(blob);
if (!actual) {
showDownloadToast('当前环境不支持 SHA-256 校验,已继续下载');
return true;
}
if (actual !== expected) {
showDownloadToast('文件完整性校验失败,请重试或让发送方重传');
return false;
}
return true;
}
async function handleFileSelected(files: File[]) {
for (const file of files) {
const result = await wsStore.sendFile(roomCode, file);
@@ -226,6 +300,8 @@ async function handleImageDownload(msg: RoomMessagePayload) {
const blob = await downloadWithProgress(url, (percent) => {
wsStore.setFileProgress(fileId, percent);
});
const verified = await verifyDownloadedBlob(msg, blob);
if (!verified) return;
wsStore.setFileProgress(fileId, 100);
const blobUrl = URL.createObjectURL(blob);
const a = document.createElement('a');
@@ -281,6 +357,8 @@ async function handleFileDownload(msg: RoomMessagePayload) {
const blob = await downloadWithProgress(url, (percent) => {
wsStore.setFileProgress(fileId, percent);
});
const verified = await verifyDownloadedBlob(msg, blob);
if (!verified) return;
wsStore.setFileProgress(fileId, 100);
const blobUrl = URL.createObjectURL(blob);
const a = document.createElement('a');

View File

@@ -1,32 +1,36 @@
import { defineConfig } from 'vite';
import { defineConfig, loadEnv } 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 },
},
},
});
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), '');
const backendTarget = env.VITE_DEV_BACKEND_TARGET || 'http://127.0.0.1:8080';
return {
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: backendTarget,
changeOrigin: true,
xfwd: true,
},
'/ws': { target: backendTarget, ws: true },
},
},
};
});