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.

This commit is contained in:
liu
2026-01-31 01:38:21 +08:00
parent f18bf32d33
commit 76273f74b9
3 changed files with 53 additions and 90 deletions

View File

@@ -1,29 +1,27 @@
# 07 - 图片预览(Base64 内联显示) # 07 - 图片预览(HTTP 传输与内联显示)
## 功能目标 ## 功能目标
- 在消息列表中对图片进行缩略图预览与放大查看。 - 在消息列表中对图片进行缩略图预览与放大查看。
- 支持通过“文件传输通道”或“图片专用消息”传输图片数据 - 图片与文件一致,统一走 **HTTP 上传/下载**,仅通过 WebSocket 广播 FILE 元数据fileId、fileName、mimeType 等),避免 WebSocket 消息大小限制(如 200KB与长传断连
## 前端Vue ## 前端Vue
- **识别规则** - **识别规则**
- `mimeType` 满足 `image/*` 时按图片渲染。 - `mimeType` 满足 `image/*` 时按图片渲染;消息类型为 `FILE`(或历史兼容的 `IMAGE`
- **展示形态(建议)** - **展示形态**
- 消息卡片中显示缩略图(限制最大宽高)。 - 消息卡片中显示缩略图(限制最大宽高)。
- 点击后弹窗预览Element Plus `el-dialog`/`el-image` 预览) - 点击后弹窗预览。
- **传输策略(两种可选)** - **传输策略**
- **复用文件分片**(推荐一致性):图片走 `FILE + CHUNK`,接收端完成后生成 Blob 并预览 - **图片一律走 HTTP**:与普通文件相同,使用 `POST /api/room/{roomCode}/file/upload` 上传,服务端落盘后广播一条 `FILE` 消息(含 `storage: 'server'`);展示时使用 `GET /api/room/{roomCode}/file/{fileId}` 作为图片 `src`,无需 base64 或 WebSocket 传图
- **小图直发**:当图片小于阈值(如 200KB时发送 `IMAGE`payload 直接携带 base64需限制大小
## 后端Spring Boot ## 后端Spring Boot
- 默认透传(与 FILE/CHUNK 一致);在生产环境建议增加大小限制与频率限制。 - 复用 `RoomFileController` 的上传/下载接口;与普通文件相同的大小与频率限制。
## 协议与数据(建议) ## 协议与数据
- 若使用 `IMAGE` - 图片消息使用 **FILE** 类型,与文件一致
- `payload.data`base64不带 dataURL 前缀或带前缀需约定) - `type: 'FILE'``storage: 'server'``fileId``fileName``fileSize``mimeType`(如 `image/png`)。
- `payload.mimeType`:如 `image/png` - 历史兼容:若存在旧版 `IMAGE` 类型且带 `imageData`base64前端仍可解析并显示。
- `payload.fileName`:(可选)
## 边界与注意点 ## 边界与注意点
- **性能**图片 base64 可能导致消息体很大,优先走分片 - **性能**大图不再经 WebSocket无 200KB 限制,由 HTTP 与服务器磁盘承载
- **安全**只当作图片二进制展示,不执行任何脚本;避免 payload 当 HTML 渲染。 - **安全**:图片仅作二进制展示,不执行脚本;避免 payload 当 HTML 渲染。

View File

@@ -18,8 +18,6 @@ const CHUNK_THRESHOLD = 32 * 1024;
const CHUNK_SIZE = 32 * 1024; const CHUNK_SIZE = 32 * 1024;
/** 分片缓存超时ms超时未收齐则清理并可选提示 */ /** 分片缓存超时ms超时未收齐则清理并可选提示 */
const CHUNK_CACHE_TTL = 60 * 1000; const CHUNK_CACHE_TTL = 60 * 1000;
/** 小图直发阈值doc07200KB 以下直接发送 base64 */
const IMAGE_INLINE_THRESHOLD = 200 * 1024;
/** 单文件大小上限2GB与后端 transfer.max-file-size 一致 */ /** 单文件大小上限2GB与后端 transfer.max-file-size 一致 */
const MAX_FILE_SIZE = 2 * 1024 * 1024 * 1024; 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' })); 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( async function sendFile(
roomCode: string, roomCode: string,
file: File, file: File,
@@ -489,53 +470,8 @@ export const useWsStore = defineStore('ws', () => {
} }
const isImage = isImageMimeType(file.type); const isImage = isImageMimeType(file.type);
const isSmallImage = isImage && file.size <= IMAGE_INLINE_THRESHOLD;
// 小图直发:使用 IMAGE 类型payload 携带 base64 // 文件与图片一律走 HTTP 上传到服务器,仅通过 WebSocket 广播 FILE 元数据,避免 WebSocket 大小限制与长传断连
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)}`; const tempFileId = `uploading_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
sendingFileIds.add(tempFileId); sendingFileIds.add(tempFileId);
sendingProgress.value[tempFileId] = 0; sendingProgress.value[tempFileId] = 0;

View File

@@ -171,24 +171,29 @@ function getFileStatus(msg: RoomMessagePayload): 'sending' | 'receiving' | 'done
/** /**
* 判断消息是否为图片类型 * 判断消息是否为图片类型
* - IMAGE 类型(小图直发 * - FILE 类型且 mimeType 为 image/*(图片统一走 HTTP与文件一致
* - FILE 类型但 mimeType 为 image/* * - IMAGE 类型(历史兼容)
*/ */
function isImageMessage(msg: RoomMessagePayload): boolean { function isImageMessage(msg: RoomMessagePayload): boolean {
if (msg.type === 'IMAGE') return true;
if (msg.type === 'FILE' && isImageMimeType(msg.mimeType)) return true; if (msg.type === 'FILE' && isImageMimeType(msg.mimeType)) return true;
if (msg.type === 'IMAGE') return true;
return false; return false;
} }
/** /**
* 获取图片消息的显示 URL * 获取图片消息的显示 URL
* 图片统一走 HTTP 传输:服务器存储的图片直接使用下载 URL历史 IMAGE+imageData 仍支持。
*/ */
function getImageSrc(msg: RoomMessagePayload): string | undefined { function getImageSrc(msg: RoomMessagePayload): string | undefined {
const fileId = msg.fileId ?? ''; const fileId = msg.fileId ?? '';
// 优先从 store 获取(包含接收完成的 blob URL 和小图直发的 data URL // 优先从 store 获取(发送方本地预览 blob、接收方 CHUNK 合并后的 blob
const storeUrl = wsStore.getImageUrl(fileId); const storeUrl = wsStore.getImageUrl(fileId);
if (storeUrl) return storeUrl; 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) { if (msg.type === 'IMAGE' && msg.imageData) {
const mimeType = msg.mimeType ?? 'image/png'; const mimeType = msg.mimeType ?? 'image/png';
return msg.imageData.startsWith('data:') 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 fileId = msg.fileId ?? '';
const fileName = msg.fileName ?? 'image.png'; 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) { if (!imageSrc) {
const isMe = msg.senderId === wsStore.myUserId; const isMe = msg.senderId === wsStore.myUserId;
showDownloadToast( showDownloadToast(
@@ -225,7 +255,6 @@ function handleImageDownload(msg: RoomMessagePayload) {
return; return;
} }
// 创建下载链接
const a = document.createElement('a'); const a = document.createElement('a');
a.href = imageSrc; a.href = imageSrc;
a.download = fileName; a.download = fileName;