From b1c51774e3bbc6aefbd6945ebef9d80e85ea44d7 Mon Sep 17 00:00:00 2001 From: bob <312777916@qq.com> Date: Tue, 15 Apr 2025 16:20:33 +0800 Subject: [PATCH] =?UTF-8?q?=E9=87=8D=E6=9E=84=EF=BC=9A=E7=94=A8=E9=A2=84?= =?UTF-8?q?=E7=AD=BE=E5=90=8DURL=E4=B8=8A=E4=BC=A0=E6=96=87=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/mts.js | 79 +++++++++++++++- src/components/common/EditAvatar.vue | 23 ++++- src/const/mtsConst.js | 4 + src/js/utils/file.js | 17 ++++ src/js/utils/image.js | 92 +++++++++++++++++++ src/views/message/components/InputEditor.vue | 34 +++++-- .../message/components/InputRecorder.vue | 20 +++- src/views/message/components/InputToolBar.vue | 36 ++++++-- src/views/message/components/MessageItem.vue | 8 +- 9 files changed, 289 insertions(+), 24 deletions(-) create mode 100644 src/const/mtsConst.js create mode 100644 src/js/utils/file.js create mode 100644 src/js/utils/image.js diff --git a/src/api/mts.js b/src/api/mts.js index bd96f72..6cf890b 100644 --- a/src/api/mts.js +++ b/src/api/mts.js @@ -1,7 +1,82 @@ import request from '@/js/utils/request' -export const mtsUploadService = (obj) => { - return request.postForm('/mts/upload', obj) +export const mtsUploadServiceForImage = async (requestBody, { originFile, thumbFile }) => { + try { + const res = await request.postForm('/mts/getUploadUrl', requestBody) + const scope = res.data.data.scope + const objectId = res.data.data.objectId + const originUrl = res.data.data.originUrl + const thumbUrl = res.data.data.thumbUrl + if (scope === 1 && originUrl && thumbUrl) { + // 如果文件之前已经上传过,直接获取下载地址 + return res + } else { + const uploadOriginUrl = res.data.data.uploadOriginUrl + const uploadThumbUrl = res.data.data.uploadThumbUrl + // 2 上传原图 + const originResponse = await fetch(uploadOriginUrl, { + method: 'PUT', + body: originFile + }) + + if (!originResponse.ok) { + throw new Error('原图上传失败') + } + + // 3 上传缩略图,如果原图和缩略图一样,就不上传 + if (uploadThumbUrl !== uploadOriginUrl) { + const thumbResponse = await fetch(uploadThumbUrl, { + method: 'PUT', + body: thumbFile + }) + + if (!thumbResponse.ok) { + throw new Error('缩略图上传失败') + } + } + + // 4 上报服务端上传成功,服务端返回预签名下载URL + const reportResponse = await request.postForm('/mts/reportUploaded', { objectId }) + return reportResponse + } + } catch (error) { + throw new Error('图片上传失败') + } + + // TODO 上传期间查询上传进度 +} + +export const mtsUploadService = async (requestBody, { originFile }) => { + try { + // 1 获取上传的预签名URL + const res = await request.postForm('/mts/getUploadUrl', requestBody) + const scope = res.data.data.scope + const objectId = res.data.data.objectId + const downloadUrl = res.data.data.downloadUrl + if (scope === 1 && downloadUrl) { + // 如果文件之前已经上传过,直接过的下载地址 + return res + } else { + const uploadUrl = res.data.data.uploadUrl + // 2 上传文件 + const uploadResponse = await fetch(uploadUrl, { + method: 'PUT', + body: originFile + }) + + if (!uploadResponse.ok) { + throw new Error('文件上传失败') + } + + // 3 上报服务端上传成功,服务端返回预签名下载URL + const reportResponse = await request.postForm('/mts/reportUploaded', { objectId }) + return reportResponse + } + } catch (error) { + throw new Error('文件上传失败') + } + + // TODO 上传期间查询上传进度 } export const mtsImageService = (obj) => { diff --git a/src/components/common/EditAvatar.vue b/src/components/common/EditAvatar.vue index 2c42377..152f243 100644 --- a/src/components/common/EditAvatar.vue +++ b/src/components/common/EditAvatar.vue @@ -2,7 +2,9 @@ import { ref, computed } from 'vue' import { useUserStore } from '@/stores' import { Plus, Check, RefreshLeft, RefreshRight, Refresh } from '@element-plus/icons-vue' -import { mtsUploadService } from '@/api/mts' +import { mtsUploadServiceForImage } from '@/api/mts' +import { getMd5 } from '@/js/utils/file' +import { generateThumb } from '@/js/utils/image' import 'vue-cropper/dist/index.css' import { VueCropper } from 'vue-cropper' @@ -81,7 +83,24 @@ const onSave = async () => { isLoading.value = true try { - const res = await mtsUploadService({ file: file, storeType: 0 }) + const md5 = await getMd5(file) + const thumbObj = await generateThumb(file) + const files = { + originFile: file, + thumbFile: thumbObj.thumbFile + } + const requestBody = { + storeType: 0, + md5, + fileName: file.name, + fileRawType: file.type, + size: file.size, + originWidth: thumbObj.originWidth, + originHeight: thumbObj.originHeight, + thumbWidth: thumbObj.thumbWidth, + thumbHeight: thumbObj.thumbHeight + } + const res = await mtsUploadServiceForImage(requestBody, files) emit('update:newAvatar', { avatarId: res.data.data.objectId, avatar: res.data.data.originUrl, diff --git a/src/const/mtsConst.js b/src/const/mtsConst.js new file mode 100644 index 0000000..bb5d919 --- /dev/null +++ b/src/const/mtsConst.js @@ -0,0 +1,4 @@ +/** + * 缩略图最大大小 + */ +export const THUMB_IMAGE_MAX = 100 * 1024 diff --git a/src/js/utils/file.js b/src/js/utils/file.js new file mode 100644 index 0000000..e4c161b --- /dev/null +++ b/src/js/utils/file.js @@ -0,0 +1,17 @@ +import CryptoJS from 'crypto-js' + +export const getMd5 = (file) => { + return new Promise((resolve, reject) => { + const reader = new FileReader() + reader.readAsArrayBuffer(file) + reader.onload = (e) => { + const arrayBuffer = e.target.result + const wordArray = CryptoJS.lib.WordArray.create(arrayBuffer) + const md5 = CryptoJS.MD5(wordArray).toString() + resolve(md5) + } + reader.onerror = () => { + reject(new Error('Failed to get md5 of file.')) + } + }) +} diff --git a/src/js/utils/image.js b/src/js/utils/image.js new file mode 100644 index 0000000..93ee98e --- /dev/null +++ b/src/js/utils/image.js @@ -0,0 +1,92 @@ +import { THUMB_IMAGE_MAX } from '@/const/mtsConst' + +/** + * 生成缩略图 + * @param {*} + * @returns 缩略图file对象,原图宽高,缩略图宽高 + */ +export const generateThumb = async (blob, originalWidth = null, originalHeight = null) => { + return new Promise((resolve, reject) => { + const reader = new FileReader() + reader.readAsDataURL(blob) + reader.onload = () => { + const img = new Image() + img.src = reader.result + img.onload = () => { + let width = originalWidth !== null ? originalWidth : img.width + let height = originalHeight !== null ? originalHeight : img.height + let thumbWidth = img.width + let thumbHeight = img.height + + if (blob.size <= THUMB_IMAGE_MAX) { + resolve({ + blob, + originWidth: width, + originHeight: height, + thumbWidth, + thumbHeight + }) + } + + let accuracy = getAccuracy(blob.size) + const scaleRatio = Math.sqrt(accuracy) // 根据 accuracy的平方根 计算缩放比例 + // 等比率缩放宽高 + thumbWidth = Math.floor(thumbWidth * scaleRatio) + thumbHeight = Math.floor(thumbHeight * scaleRatio) + const canvas = document.createElement('canvas') + const ctx = canvas.getContext('2d') + canvas.width = thumbWidth + canvas.height = thumbHeight + ctx.drawImage(img, 0, 0, thumbWidth, thumbHeight) + + canvas.toBlob( + async (blob) => { + if (blob) { + console.log(blob.size) + if (blob.size <= THUMB_IMAGE_MAX) { + const thumbFile = new File([blob], blob.name, { + type: blob.type + }) + resolve({ + thumbFile, + originWidth: width, + originHeight: height, + thumbWidth, + thumbHeight + }) + } else { + const result = await generateThumb(blob, width, height) + resolve(result) + } + } else { + reject(new Error('Failed to generate thumb.')) + } + }, + blob.type, + accuracy + ) + } + img.onerror = () => { + reject(new Error('Failed to load image.')) + } + } + reader.onerror = () => { + reject(new Error('Failed to read file.')) + } + }) +} + +// 自动调节精度(经验数值) +function getAccuracy(size) { + let accuracy + if (size < 1024 * 1024) { + accuracy = 0.6 + } else if (size < 2047 * 1024) { + accuracy = 0.5 + } else if (size < 3275 * 1024) { + accuracy = 0.45 + } else { + accuracy = 0.4 + } + return accuracy +} diff --git a/src/views/message/components/InputEditor.vue b/src/views/message/components/InputEditor.vue index 2c2fe44..8a9ce50 100644 --- a/src/views/message/components/InputEditor.vue +++ b/src/views/message/components/InputEditor.vue @@ -7,8 +7,10 @@ import { useMessageStore, useImageStore } from '@/stores' import { ElMessage } from 'element-plus' import { emojis } from '@/js/utils/emojis' import { base64ToFile } from '@/js/utils/common' -import { mtsUploadService } from '@/api/mts' +import { mtsUploadServiceForImage } from '@/api/mts' import { msgContentType, msgFileUploadStatus } from '@/const/msgConst' +import { getMd5 } from '@/js/utils/file' +import { generateThumb } from '@/js/utils/image' const props = defineProps(['sessionId', 'draft']) const emit = defineEmits(['saveLocalMsg', 'sendMessage']) @@ -43,7 +45,7 @@ onBeforeUnmount(async () => { someUploadedFailFn: () => {}, allUploadedSuccessFn: () => {} } - const contentObj = parseContent(callbacks) + const contentObj = await parseContent(callbacks) const fn = (content) => { // 草稿若发生变动,则触发存储 @@ -76,7 +78,7 @@ onUnmounted(() => { * 解析输入框内容 * @param callbacks 解析过程中需要触发的回调 */ -const parseContent = (callbacks) => { +const parseContent = async (callbacks) => { const delta = getQuill().getContents() let contentFromLocal = new Array(delta.ops.length).fill('') let contentFromServer = new Array(delta.ops.length).fill('') @@ -128,8 +130,26 @@ const parseContent = (callbacks) => { }) contentFromLocal[index] = `{${tempObjectId}}` + const md5 = await getMd5(file) + const thumbObj = await generateThumb(file) + const files = { + originFile: file, + thumbFile: thumbObj.thumbFile + } + const requestBody = { + storeType: 0, + md5, + fileName: file.name, + fileRawType: file.type, + size: file.size, + originWidth: thumbObj.originWidth, + originHeight: thumbObj.originHeight, + thumbWidth: thumbObj.thumbWidth, + thumbHeight: thumbObj.thumbHeight + } + //上传图片至服务端 - mtsUploadService({ file: file, storeType: 1 }) + mtsUploadServiceForImage(requestBody, files) .then((res) => { imageData.setImage(res.data.data) // 缓存image数据 uploadSuccessCount++ @@ -165,13 +185,13 @@ const parseContent = (callbacks) => { // 监控session发生了切换 watch( () => props.sessionId, - (newSessionId, oldSessionId) => { + async (newSessionId, oldSessionId) => { const callbacks = { someOneUploadedSuccessFn: () => {}, someUploadedFailFn: () => {}, allUploadedSuccessFn: () => {} } - const contentObj = parseContent(callbacks) + const contentObj = await parseContent(callbacks) const fn = (content) => { // 草稿若发生变动,则触发存储 @@ -242,7 +262,7 @@ const handleEnter = async () => { allUploadedSuccessFn: () => {} } - const contentObj = parseContent(callbacks) + const contentObj = await parseContent(callbacks) const content = contentObj.contentFromLocal.join('').trim() if (!content) { diff --git a/src/views/message/components/InputRecorder.vue b/src/views/message/components/InputRecorder.vue index cea78bb..1a39bd8 100644 --- a/src/views/message/components/InputRecorder.vue +++ b/src/views/message/components/InputRecorder.vue @@ -6,6 +6,7 @@ import { useAudioStore } from '@/stores' import { mtsUploadService } from '@/api/mts' import { v4 as uuidv4 } from 'uuid' import { msgContentType, msgFileUploadStatus } from '@/const/msgConst' +import { getMd5 } from '@/js/utils/file' const emit = defineEmits(['exit', 'sendMessage', 'saveLocalMsg']) @@ -142,7 +143,7 @@ const stopRecording = () => { } } -const uploadRecord = () => { +const uploadRecord = async () => { const fileName = `${uuidv4()}.${fileSuffix}` const file = new File([recordBlob.value], fileName, { type: recordType }) @@ -153,7 +154,7 @@ const uploadRecord = () => { audioData.setAudio({ objectId: tempObjectId, duration: duration, - url: localSrc, + downloadUrl: localSrc, fileName: file.name, size: file.size }) @@ -168,7 +169,20 @@ const uploadRecord = () => { msg.uploadStatus = msgFileUploadStatus.UPLOADING msg.uploadProgress = 0 - mtsUploadService({ file, storeType: 1, duration: duration }) + const md5 = await getMd5(file) + const files = { + originFile: file + } + const requestBody = { + storeType: 0, + md5, + fileName: file.name, + fileRawType: file.type, + size: file.size, + audioDuration: duration + } + + mtsUploadService(requestBody, files) .then((res) => { if (res.data.code === 0) { audioData.setAudio(res.data.data) // 缓存服务端响应的audio数据 diff --git a/src/views/message/components/InputToolBar.vue b/src/views/message/components/InputToolBar.vue index 907df25..61e072c 100644 --- a/src/views/message/components/InputToolBar.vue +++ b/src/views/message/components/InputToolBar.vue @@ -9,7 +9,7 @@ import CodeIcon from '@/assets/svg/code.svg' import VoteIcon from '@/assets/svg/vote.svg' import EmojiBox from './EmojiBox.vue' import InputTool from '@/views/message/components/InputTool.vue' -import { mtsUploadService } from '@/api/mts' +import { mtsUploadService, mtsUploadServiceForImage } from '@/api/mts' import { useMessageStore, useImageStore, @@ -19,6 +19,8 @@ import { } from '@/stores' import { MsgType } from '@/proto/msg' import { msgContentType, msgFileUploadStatus } from '@/const/msgConst' +import { generateThumb } from '@/js/utils/image' +import { getMd5 } from '@/js/utils/file' const props = defineProps(['sessionId', 'isShowToolSet']) const emit = defineEmits(['sendEmoji', 'showRecorder', 'sendMessage', 'saveLocalMsg']) @@ -30,14 +32,17 @@ const videoData = useVideoStore() const documentData = useDocumentStore() const isShowEmojiBox = ref(false) -const onSelectedFile = (file) => { +const onSelectedFile = async (file) => { if (!file) { return } let contentType = msgContentType.DOCUMENT + const md5 = await getMd5(file.raw) + let thumbObj if (file.raw.type.startsWith('image/')) { contentType = msgContentType.IMAGE + thumbObj = await generateThumb(file.raw) } else if (file.raw.type.startsWith('audio/')) { contentType = msgContentType.AUDIO } else if (file.raw.type.startsWith('video/')) { @@ -56,7 +61,26 @@ const onSelectedFile = (file) => { msg.uploadStatus = msgFileUploadStatus.UPLOADING msg.uploadProgress = 0 - mtsUploadService({ file: file.raw, storeType: 1 }) + let requestApi = mtsUploadService + const requestBody = { + storeType: 1, + md5, + fileName: file.name, + fileRawType: file.raw.type, + size: file.raw.size + } + const files = { originFile: file.raw } + + if (contentType === msgContentType.IMAGE) { + requestBody.originWidth = thumbObj.originWidth + requestBody.originHeight = thumbObj.originHeight + requestBody.thumbWidth = thumbObj.thumbWidth + requestBody.thumbHeight = thumbObj.thumbHeight + files.thumbFile = thumbObj.thumbFile + requestApi = mtsUploadServiceForImage + } + + requestApi(requestBody, files) .then((res) => { if (res.data.code === 0) { setStoreData(contentType, res.data.data) @@ -93,7 +117,7 @@ const setLocalData = (contentType, file) => { case msgContentType.AUDIO: audioData.setAudio({ objectId: file.uid, - url: localSrc, + downloadUrl: localSrc, fileName: file.name, size: file.raw.size }) @@ -101,7 +125,7 @@ const setLocalData = (contentType, file) => { case msgContentType.VIDEO: videoData.setVideo({ objectId: file.uid, - url: localSrc, + downloadUrl: localSrc, fileName: file.name, size: file.raw.size }) @@ -111,7 +135,7 @@ const setLocalData = (contentType, file) => { documentData.setDocument({ objectId: file.uid, documentType: file.raw.type, - url: localSrc, + downloadUrl: localSrc, fileName: file.name, size: file.raw.size }) diff --git a/src/views/message/components/MessageItem.vue b/src/views/message/components/MessageItem.vue index bb4eefc..6be59dc 100644 --- a/src/views/message/components/MessageItem.vue +++ b/src/views/message/components/MessageItem.vue @@ -150,7 +150,7 @@ const renderEmoji = (content) => { const renderVideo = (content) => { const videoId = content - const url = videoData.video[videoId]?.url + const url = videoData.video[videoId]?.downloadUrl if (url) { return h(VideoMsgBox, { videoId, @@ -186,7 +186,7 @@ const renderImage = (content, isForMix = false) => { const renderRecording = (content) => { const audioId = content - const url = audioData.audio[audioId]?.url + const url = audioData.audio[audioId]?.downloadUrl const duration = audioData.audio[audioId]?.duration if (url) { return h(RecordingMsgBox, { @@ -203,7 +203,7 @@ const renderRecording = (content) => { const renderAudio = (content) => { const audioId = content - const url = audioData.audio[audioId]?.url + const url = audioData.audio[audioId]?.downloadUrl if (url) { return h(AudioMsgBox, { url, @@ -220,7 +220,7 @@ const renderAudio = (content) => { const renderDocument = (content) => { const documentId = content - const url = documentData.document[documentId]?.url + const url = documentData.document[documentId]?.downloadUrl if (url) { return h(DocumentMsgBox, { url,