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)
|
## 前端(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 渲染。
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
/** 小图直发阈值(doc07:200KB 以下直接发送 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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user