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:
@@ -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 渲染。
|
||||
|
||||
|
||||
@@ -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<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,
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user