From 76273f74b97f2c1162c2dbcc3b2b58521a4c0a96 Mon Sep 17 00:00:00 2001 From: liu <362165265@qq.com> Date: Sat, 31 Jan 2026 01:38:21 +0800 Subject: [PATCH] Refactor image handling to unify image transmission via HTTP; remove base64 handling for small images and update related frontend logic for improved performance and compatibility. --- docs/07-图片预览(Base64内联显示).md | 30 ++++++------ frontend/src/stores/wsStore.ts | 66 +-------------------------- frontend/src/views/RoomView.vue | 47 +++++++++++++++---- 3 files changed, 53 insertions(+), 90 deletions(-) diff --git a/docs/07-图片预览(Base64内联显示).md b/docs/07-图片预览(Base64内联显示).md index f067e8e..2056320 100644 --- a/docs/07-图片预览(Base64内联显示).md +++ b/docs/07-图片预览(Base64内联显示).md @@ -1,29 +1,27 @@ -# 07 - 图片预览(Base64 内联显示) +# 07 - 图片预览(HTTP 传输与内联显示) ## 功能目标 - 在消息列表中对图片进行缩略图预览与放大查看。 -- 支持通过“文件传输通道”或“图片专用消息”传输图片数据。 +- 图片与文件一致,统一走 **HTTP 上传/下载**,仅通过 WebSocket 广播 FILE 元数据(fileId、fileName、mimeType 等),避免 WebSocket 消息大小限制(如 200KB)与长传断连。 ## 前端(Vue) - **识别规则** - - `mimeType` 满足 `image/*` 时按图片渲染。 -- **展示形态(建议)** + - `mimeType` 满足 `image/*` 时按图片渲染;消息类型为 `FILE`(或历史兼容的 `IMAGE`)。 +- **展示形态** - 消息卡片中显示缩略图(限制最大宽高)。 - - 点击后弹窗预览(Element Plus `el-dialog`/`el-image` 预览)。 -- **传输策略(两种可选)** - - **复用文件分片**(推荐一致性):图片走 `FILE + CHUNK`,接收端完成后生成 Blob 并预览。 - - **小图直发**:当图片小于阈值(如 200KB)时发送 `IMAGE`,payload 直接携带 base64(需限制大小)。 + - 点击后弹窗预览。 +- **传输策略** + - **图片一律走 HTTP**:与普通文件相同,使用 `POST /api/room/{roomCode}/file/upload` 上传,服务端落盘后广播一条 `FILE` 消息(含 `storage: 'server'`);展示时使用 `GET /api/room/{roomCode}/file/{fileId}` 作为图片 `src`,无需 base64 或 WebSocket 传图。 ## 后端(Spring Boot) -- 默认透传(与 FILE/CHUNK 一致);在生产环境建议增加大小限制与频率限制。 +- 复用 `RoomFileController` 的上传/下载接口;与普通文件相同的大小与频率限制。 -## 协议与数据(建议) -- 若使用 `IMAGE`: - - `payload.data`:base64(不带 dataURL 前缀或带前缀需约定) - - `payload.mimeType`:如 `image/png` - - `payload.fileName`:(可选) +## 协议与数据 +- 图片消息使用 **FILE** 类型,与文件一致: + - `type: 'FILE'`,`storage: 'server'`,`fileId`、`fileName`、`fileSize`、`mimeType`(如 `image/png`)。 +- 历史兼容:若存在旧版 `IMAGE` 类型且带 `imageData`(base64),前端仍可解析并显示。 ## 边界与注意点 -- **性能**:图片 base64 可能导致消息体很大,优先走分片。 -- **安全**:只当作图片二进制展示,不执行任何脚本;避免把 payload 当 HTML 渲染。 +- **性能**:大图不再经 WebSocket,无 200KB 限制,由 HTTP 与服务器磁盘承载。 +- **安全**:图片仅作二进制展示,不执行脚本;避免将 payload 当 HTML 渲染。 diff --git a/frontend/src/stores/wsStore.ts b/frontend/src/stores/wsStore.ts index 03c7a19..114f205 100644 --- a/frontend/src/stores/wsStore.ts +++ b/frontend/src/stores/wsStore.ts @@ -18,8 +18,6 @@ const CHUNK_THRESHOLD = 32 * 1024; const CHUNK_SIZE = 32 * 1024; /** 分片缓存超时(ms),超时未收齐则清理并可选提示 */ const CHUNK_CACHE_TTL = 60 * 1000; -/** 小图直发阈值(doc07:200KB 以下直接发送 base64) */ -const IMAGE_INLINE_THRESHOLD = 200 * 1024; /** 单文件大小上限(2GB),与后端 transfer.max-file-size 一致 */ const MAX_FILE_SIZE = 2 * 1024 * 1024 * 1024; @@ -455,23 +453,6 @@ export const useWsStore = defineStore('ws', () => { return URL.createObjectURL(new Blob([text], { type: 'text/plain;charset=utf-8' })); } - /** - * 将 File 转换为 base64 字符串 - */ - async function fileToBase64(file: File): Promise { - 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, @@ -489,53 +470,8 @@ export const useWsStore = defineStore('ws', () => { } 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 元数据,避免长传断连 + // 文件与图片一律走 HTTP 上传到服务器,仅通过 WebSocket 广播 FILE 元数据,避免 WebSocket 大小限制与长传断连 const tempFileId = `uploading_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`; sendingFileIds.add(tempFileId); sendingProgress.value[tempFileId] = 0; diff --git a/frontend/src/views/RoomView.vue b/frontend/src/views/RoomView.vue index 08b25a1..ff84d52 100644 --- a/frontend/src/views/RoomView.vue +++ b/frontend/src/views/RoomView.vue @@ -171,24 +171,29 @@ function getFileStatus(msg: RoomMessagePayload): 'sending' | 'receiving' | 'done /** * 判断消息是否为图片类型 - * - IMAGE 类型(小图直发) - * - FILE 类型但 mimeType 为 image/* + * - FILE 类型且 mimeType 为 image/*(图片统一走 HTTP,与文件一致) + * - IMAGE 类型(历史兼容) */ function isImageMessage(msg: RoomMessagePayload): boolean { - if (msg.type === 'IMAGE') return true; if (msg.type === 'FILE' && isImageMimeType(msg.mimeType)) return true; + if (msg.type === 'IMAGE') return true; return false; } /** * 获取图片消息的显示 URL + * 图片统一走 HTTP 传输:服务器存储的图片直接使用下载 URL;历史 IMAGE+imageData 仍支持。 */ function getImageSrc(msg: RoomMessagePayload): string | undefined { const fileId = msg.fileId ?? ''; - // 优先从 store 获取(包含接收完成的 blob URL 和小图直发的 data URL) + // 优先从 store 获取(发送方本地预览 blob、接收方 CHUNK 合并后的 blob) const storeUrl = wsStore.getImageUrl(fileId); if (storeUrl) return storeUrl; - // 如果是 IMAGE 类型且有 imageData,直接构造 data URL + // 服务器存储的图片:直接使用 HTTP 下载 URL 显示 + if (fileId && wsStore.isServerFile(msg) && isImageMimeType(msg.mimeType)) { + return getFileDownloadUrl(roomCode, fileId); + } + // 历史消息兼容:IMAGE 类型带 imageData 的旧数据 if (msg.type === 'IMAGE' && msg.imageData) { const mimeType = msg.mimeType ?? 'image/png'; return msg.imageData.startsWith('data:') @@ -208,13 +213,38 @@ async function handleFileSelected(files: File[]) { } /** - * 下载图片 + * 下载图片:服务器存储走 HTTP 下载(带进度),其余用当前显示的 URL 触发下载。 */ -function handleImageDownload(msg: RoomMessagePayload) { +async function handleImageDownload(msg: RoomMessagePayload) { const fileId = msg.fileId ?? ''; const fileName = msg.fileName ?? 'image.png'; - const imageSrc = getImageSrc(msg); + if (wsStore.isServerFile(msg) && fileId) { + const url = getFileDownloadUrl(roomCode, fileId); + wsStore.setDownloading(fileId, true); + 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); + URL.revokeObjectURL(blobUrl); + } catch (e) { + showDownloadToast(e instanceof Error ? e.message : '下载失败'); + } finally { + wsStore.setDownloading(fileId, false); + } + return; + } + + const imageSrc = getImageSrc(msg); if (!imageSrc) { const isMe = msg.senderId === wsStore.myUserId; showDownloadToast( @@ -225,7 +255,6 @@ function handleImageDownload(msg: RoomMessagePayload) { return; } - // 创建下载链接 const a = document.createElement('a'); a.href = imageSrc; a.download = fileName;