First, install the required dependencies:
npm install cos-js-sdk-v5 spark-md5 --save
1. Create a utility file for cloud storage operatiosn
Create src/utils/cloud-storage.js:
import COS from 'cos-js-sdk-v5'
import SparkMD5 from 'spark-md5'
import { getTempCredentials, getCloudConfig } from '@/api/storage'
let fileKey = ''
// Initialize Tencent Cloud Object Storage client
const cosClient = new COS({
async getAuthorization(options, callback) {
const response = await getTempCredentials()
const credentials = response.data
const auth = {
TmpSecretId: credentials.tmpSecretId,
TmpSecretKey: credentials.tmpSecretKey,
XCosSecurityToken: credentials.sessionToken,
StartTime: credentials.startTime,
ExpiredTime: credentials.expiredTime,
}
callback(auth)
},
FileParallelLimit: 3,
ChunkParallelLimit: 8,
ChunkSize: 1024 * 1024 * 8,
})
// Get public URL for uploaded file
export async function getFileUrl() {
const response = await getCloudConfig()
const url = cosClient.getObjectUrl({
Bucket: response.data.bucket,
Region: response.data.region,
Key: fileKey,
Sign: false,
})
return url
}
// Calculate file MD5 hash
function calculateFileHash(file, callback) {
const reader = new FileReader()
const chunkSize = 2 * 1024 * 1024
const chunks = Math.ceil(file.size / chunkSize)
let currentChunk = 0
const spark = new SparkMD5()
reader.onload = function(e) {
spark.appendBinary(e.target.result)
currentChunk++
if (currentChunk < chunks) {
loadNext()
} else {
callback(spark.end())
}
}
function loadNext() {
const start = currentChunk * chunkSize
const end = start + chunkSize >= file.size ? file.size : start + chunkSize
reader.readAsBinaryString(file.slice(start, end))
}
loadNext()
}
// Upload large files using multipart upload
export function uploadLargeFile(path, file, callback, progressCallback) {
return new Promise(resolve => {
getCloudConfig().then(response => {
calculateFileHash(file, hash => {
file.hash = hash
const extension = file.name.substr(file.name.lastIndexOf('.'))
fileKey = path + hash + Date.now() + extension
cosClient.sliceUploadFile({
Bucket: response.data.bucket,
Region: response.data.region,
Key: fileKey,
Body: file,
onProgress(progressData) {
progressCallback(progressData.percent)
},
}, async (err, data) => {
if (err) {
callback(err)
resolve(err)
} else {
data.url = await getFileUrl()
callback(null, data)
resolve(data)
}
})
})
})
})
}
// Upload small files using simple upload
export function uploadSmallFile(path, file, callback, progressCallback) {
try {
getCloudConfig().then(response => {
calculateFileHash(file, hash => {
file.hash = hash
const extension = file.name.substr(file.name.lastIndexOf('.'))
fileKey = path + hash + Date.now() + extension
cosClient.putObject({
Bucket: response.data.bucket,
Region: response.data.region,
Key: fileKey,
Body: file,
onProgress(progressData) {
progressCallback(progressData.percent)
},
}, async (err, data) => {
if (err) {
callback(err)
} else {
data.url = await getFileUrl()
callback(null, data)
}
})
})
})
} catch (error) {
console.error('Upload error:', error)
}
}
// Delete file from cloud storage
export function deleteFile(path, fileName, callback, customKey) {
getCloudConfig().then(response => {
const key = customKey ? path + customKey : path + fileName
cosClient.deleteObject({
Bucket: response.data.bucket,
Region: response.data.region,
Key: key,
}, (err, data) => {
if (err) {
callback(err)
} else {
callback(null, data)
}
})
})
}
2. Implementing image upload component in Vue
import { uploadSmallFile } from '@/utils/cloud-storage'
import dayjs from 'dayjs'
const { proxy } = getCurrentInstance()
function handleImageUpload({ file }) {
const currentDate = dayjs().format('YYYY/MM/DD')
uploadSmallFile(`/uploads/products/${currentDate}/`, file, (error, result) => {
if (error) {
uploadCount.value--
proxy.$modal.closeLoading()
proxy.$modal.msgError('Upload failed')
proxy.$refs.imageUpload.handleRemove(file)
completeUpload()
} else {
uploadedFiles.value.push({ name: result.url, url: result.url })
completeUpload()
proxy.$modal.msgSuccess('Upload successful!')
}
})
}
function completeUpload() {
if (uploadCount.value > 0 && uploadedFiles.value.length === uploadCount.value) {
fileList.value = fileList.value
.filter((f) => f.url !== undefined)
.concat(uploadedFiles.value)
uploadedFiles.value = []
uploadCount.value = 0
emit('update:modelValue', fileListToString(fileList.value))
proxy.$modal.closeLoading()
}
}
Complete component implementation:
<template>
<div class="image-upload-component">
<el-upload
multiple
action="#"
:http-request="handleImageUpload"
list-type="picture-card"
:before-upload="beforeUpload"
:limit="maxFiles"
:on-error="handleUploadError"
:on-exceed="handleExceed"
ref="imageUpload"
:before-remove="handleRemove"
:show-file-list="true"
:file-list="fileList"
:on-preview="previewImage"
:class="{ hide: fileList.length >= maxFiles }"
>
<el-icon class="upload-icon">
<plus />
</el-icon>
</el-upload>
<div class="upload-tip" v-if="showTip">
Please upload
<template v-if="maxFileSize">
files smaller than <b style="color: #f56c6c">{{ maxFileSize }}MB</b>
</template>
<template v-if="allowedTypes">
with format <b style="color: #f56c6c">{{ allowedTypes.join('/') }}</b>
</template>
</div>
<el-dialog
v-model="previewVisible"
title="Preview"
width="800px"
append-to-body
>
<img
:src="previewUrl"
style="display: block; max-width: 100%; margin: 0 auto"
/>
</el-dialog>
</div>
</template>
<script setup>
import { defineEmits } from 'vue'
import { uploadSmallFile } from '@/utils/cloud-storage'
import dayjs from 'dayjs'
const props = defineProps({
modelValue: [String, Object, Array],
maxFiles: {
type: Number,
default: 5,
},
maxFileSize: {
type: Number,
default: 5,
},
allowedTypes: {
type: Array,
default: () => ['png', 'jpg', 'jpeg'],
},
showTip: {
type: Boolean,
default: true,
},
})
const { proxy } = getCurrentInstance()
const emit = defineEmits(['update:modelValue'])
const uploadCount = ref(0)
const uploadedFiles = ref([])
const previewUrl = ref('')
const previewVisible = ref(false)
const fileList = ref([])
const showUploadTip = computed(
() => props.showTip && (props.allowedTypes || props.maxFileSize),
)
watch(
() => props.modelValue,
(val) => {
if (val) {
const list = Array.isArray(val) ? val : props.modelValue.split(',')
fileList.value = list.map((item) => {
if (typeof item === 'string') {
item = { name: item, url: item }
}
return item
})
return fileList.value
}
fileList.value = []
return []
},
{ deep: true, immediate: true },
)
function handleImageUpload({ file }) {
const currentDate = dayjs().format('YYYY/MM/DD')
uploadSmallFile(`/uploads/products/${currentDate}/`, file, (error, result) => {
if (error) {
uploadCount.value--
proxy.$modal.closeLoading()
proxy.$modal.msgError('Upload failed')
proxy.$refs.imageUpload.handleRemove(file)
completeUpload()
} else {
const imageUrl = `http://${result.Location}`
uploadedFiles.value.push({ name: imageUrl, url: imageUrl })
proxy.$modal.msgSuccess('Upload successful!')
completeUpload()
}
})
}
function beforeUpload(file) {
let isValidType = false
if (props.allowedTypes.length) {
let fileExtension = ''
if (file.name.lastIndexOf('.') > -1) {
fileExtension = file.name.slice(file.name.lastIndexOf('.') + 1)
}
isValidType = props.allowedTypes.some((type) => {
if (file.type.indexOf(type) > -1) return true
if (fileExtension && fileExtension.indexOf(type) > -1) return true
return false
})
} else {
isValidType = file.type.indexOf('image') > -1
}
if (!isValidType) {
proxy.$modal.msgError(
`Invalid file format. Please upload ${props.allowedTypes.join('/')} image files.`,
)
return false
}
if (props.maxFileSize) {
const isValidSize = file.size / 1024 / 1024 < props.maxFileSize
if (!isValidSize) {
proxy.$modal.msgError(`File size cannot exceed ${props.maxFileSize} MB!`)
return false
}
}
proxy.$modal.loading('Uploading image, please wait...')
uploadCount.value++
}
function handleExceed() {
proxy.$modal.msgError(`Cannot upload more than ${props.maxFiles} files!`)
}
function handleRemove(file) {
const fileIndex = fileList.value.map((f) => f.name).indexOf(file.name)
if (fileIndex > -1 && uploadedFiles.value.length === uploadCount.value) {
fileList.value.splice(fileIndex, 1)
emit('update:modelValue', fileListToString(fileList.value))
return false
}
}
function completeUpload() {
if (uploadCount.value > 0 && uploadedFiles.value.length === uploadCount.value) {
fileList.value = fileList.value
.filter((f) => f.url !== undefined)
.concat(uploadedFiles.value)
uploadedFiles.value = []
uploadCount.value = 0
emit('update:modelValue', fileListToString(fileList.value))
proxy.$modal.closeLoading()
}
}
function handleUploadError() {
proxy.$modal.msgError('Image upload failed')
proxy.$modal.closeLoading()
}
function previewImage(file) {
previewUrl.value = file.url
previewVisible.value = true
}
function fileListToString(arr = [], separator = ',') {
if (Array.isArray(arr)) {
return arr.map(i => i.url).join(separator)
}
return console.error('Input is not an array')
}
</script>
<style scoped lang="scss">
:deep(.hide .el-upload--picture-card) {
display: none;
}
</style>
3. Integratino with rich text editor
// Editor configuration
const editorConfig = ref({
placeholder: computed(() => props.placeholder),
MENU_CONF: {
uploadImage: {
customUpload(file, insertFn) {
const currentDate = dayjs().format('YYYY/MM/DD')
uploadSmallFile(`/uploads/content/${currentDate}/`, file, (error, result) => {
if (error) {
ElMessage({
message: `${file.name} upload failed`,
type: 'error',
})
} else {
const imageUrl = `http://${result.Location}`
insertFn(imageUrl, '', '')
}
})
},
fieldName: 'file',
maxFileSize: 5 * 1024 * 1024,
maxNumberOfFiles: 10,
allowedFileTypes: ['image/*'],
headers: {
Authorization: `Bearer ${getAuthToken()}`,
},
withCredentials: true,
timeout: 5 * 1000,
onFailed(file) {
ElMessage({
message: `${file.name} upload failed`,
type: 'error',
})
},
onError(file, err) {
handleError(err)
},
customInsert(res, insertFn) {
insertFn(res.url, '', '')
},
},
uploadVideo: {
customUpload(file, insertFn) {
const currentDate = dayjs().format('YYYY/MM/DD')
uploadSmallFile(`/uploads/content/${currentDate}/`, file, (error, result) => {
if (error) {
ElMessage({
message: `${file.name} upload failed`,
type: 'error',
})
} else {
const videoUrl = `http://${result.Location}`
insertFn(videoUrl, '', '')
}
})
},
fieldName: 'file',
maxFileSize: 20 * 1024 * 1024,
maxNumberOfFiles: 10,
allowedFileTypes: ['video/*'],
headers: {
Authorization: `Bearer ${getAuthToken()}`,
},
withCredentials: true,
timeout: 15 * 1000,
onFailed(file) {
ElMessage({
message: `${file.name} upload failed`,
type: 'error',
})
},
onError(file, err) {
handleError(err)
},
customInsert(res, insertFn) {
insertFn(res.url, '', '')
},
},
},
})
Complete editor component:
<template>
<div class="editor-container">
<Toolbar
:editor="editorRef"
:defaultConfig="toolbarConfig"
class="editor-toolbar"
/>
<Editor
v-model="contentHtml"
:defaultConfig="editorConfig"
:style="`height: ${height}px; overflow-y: hidden`"
@onCreated="handleCreated"
/>
</div>
</template>
<script setup>
import { computed, onBeforeUnmount, ref, shallowRef } from 'vue'
import { Editor, Toolbar } from '@wangeditor/editor-for-vue'
import { ElMessage } from 'element-plus'
import { getAuthToken } from '@/utils/auth'
import { uploadSmallFile } from '@/utils/cloud-storage'
import dayjs from 'dayjs'
const props = defineProps({
content: {
type: String,
default: '',
},
height: {
type: Number,
default: 400,
},
disabled: {
type: Boolean,
default: false,
},
placeholder: {
type: String,
default: 'Please enter content',
},
})
const editorRef = shallowRef()
const contentHtml = ref()
watch(
() => props.content,
() => {
contentHtml.value = props.content
},
)
const toolbarConfig = ref({})
const editorConfig = ref({
placeholder: computed(() => props.placeholder),
MENU_CONF: {
uploadImage: {
customUpload(file, insertFn) {
const currentDate = dayjs().format('YYYY/MM/DD')
uploadSmallFile(`/uploads/content/${currentDate}/`, file, (error, result) => {
if (error) {
ElMessage({
message: `${file.name} upload failed`,
type: 'error',
})
} else {
const imageUrl = `http://${result.Location}`
insertFn(imageUrl, '', '')
}
})
},
fieldName: 'file',
maxFileSize: 5 * 1024 * 1024,
maxNumberOfFiles: 10,
allowedFileTypes: ['image/*'],
headers: {
Authorization: `Bearer ${getAuthToken()}`,
},
withCredentials: true,
timeout: 5 * 1000,
onFailed(file) {
ElMessage({
message: `${file.name} upload failed`,
type: 'error',
})
},
onError(file, err) {
handleError(err)
},
customInsert(res, insertFn) {
insertFn(res.url, '', '')
},
},
uploadVideo: {
customUpload(file, insertFn) {
const currentDate = dayjs().format('YYYY/MM/DD')
uploadSmallFile(`/uploads/content/${currentDate}/`, file, (error, result) => {
if (error) {
ElMessage({
message: `${file.name} upload failed`,
type: 'error',
})
} else {
const videoUrl = `http://${result.Location}`
insertFn(videoUrl, '', '')
}
})
},
fieldName: 'file',
maxFileSize: 20 * 1024 * 1024,
maxNumberOfFiles: 10,
allowedFileTypes: ['video/*'],
headers: {
Authorization: `Bearer ${getAuthToken()}`,
},
withCredentials: true,
timeout: 15 * 1000,
onFailed(file) {
ElMessage({
message: `${file.name} upload failed`,
type: 'error',
})
},
onError(file, err) {
handleError(err)
},
customInsert(res, insertFn) {
insertFn(res.url, '', '')
},
},
},
})
function handleError(err) {
err = String(err)
err = err.replace(/Error: /g, 'Error: ')
err = err.replace(
/exceeds maximum allowed size of/g,
'exceeds maximum allowed size of',
)
ElMessage({
message: `${err}`,
type: 'error',
})
}
const getHtmlContent = () => {
const result = editorRef.value.getHtml()
if (result !== '<p><br></p>') {
return result
}
return ''
}
defineExpose({
getHtmlContent,
})
const handleCreated = (editor) => {
editorRef.value = editor
}
onBeforeUnmount(() => {
const editor = editorRef.value
if (editor == null) return
editor.destroy()
})
</script>
<style src="@wangeditor/editor/dist/css/style.css"></style>
<style scoped>
.editor-container {
border: 1px solid #ccc;
}
.editor-toolbar {
border-bottom: 1px solid #ccc;
}
</style>
4. Avatar cropping and upload
import { uploadSmallFile } from '@/utils/cloud-storage'
function uploadAvatar() {
proxy.$refs.cropper.getCropBlob(data => {
const file = convertBlobToFile(data, 'avatar.png')
const currentDate = dayjs().format('YYYY/MM/DD')
uploadSmallFile(`/uploads/avatars/${currentDate}/`, file, (error, result) => {
if (error) {
proxy.$modal.error(`Upload failed: ${error}`)
} else {
dialogVisible.value = false
const imageUrl = `http://${result.Location}`
avatarOptions.img = imageUrl
emit('update:modelValue', avatarOptions.img)
userStore.avatar = avatarOptions.img
proxy.$modal.msgSuccess('Avatar updated successfully')
previewVisible.value = false
}
})
})
}
Complete avatar component:
<template>
<div class="avatar-container" @click="openCropper()">
<img :src="avatarOptions.img" title="Click to upload avatar" class="avatar-preview" />
<el-dialog :title="dialogTitle" v-model="dialogVisible" width="800px" append-to-body @opened="modalOpened" @close="closeDialog">
<el-row>
<el-col :xs="24" :md="12" :style="{ height: '350px' }">
<vue-cropper
ref="cropper"
:img="avatarOptions.img"
:info="true"
:autoCrop="avatarOptions.autoCrop"
:autoCropWidth="avatarOptions.autoCropWidth"
:autoCropHeight="avatarOptions.autoCropHeight"
:fixedBox="avatarOptions.fixedBox"
:outputType="avatarOptions.outputType"
@realTime="updatePreview"
v-if="previewVisible"
/>
</el-col>
<el-col :xs="24" :md="12" :style="{ height: '350px' }">
<div class="avatar-preview-container">
<img :src="avatarOptions.previews.url" :style="avatarOptions.previews.img" />
</div>
</el-col>
</el-row>
<br />
<el-row>
<el-col :lg="2" :md="2">
<el-upload
action="#"
:http-request="customUpload"
:show-file-list="false"
:before-upload="beforeFileUpload"
>
<el-button>
Select
<el-icon class="el-icon--right"><Upload /></el-icon>
</el-button>
</el-upload>
</el-col>
<el-col :lg="{ span: 1, offset: 2 }" :md="2">
<el-button icon="Plus" @click="zoomIn"></el-button>
</el-col>
<el-col :lg="{ span: 1, offset: 1 }" :md="2">
<el-button icon="Minus" @click="zoomOut"></el-button>
</el-col>
<el-col :lg="{ span: 1, offset: 1 }" :md="2">
<el-button icon="RefreshLeft" @click="rotateLeft"></el-button>
</el-col>
<el-col :lg="{ span: 1, offset: 1 }" :md="2">
<el-button icon="RefreshRight" @click="rotateRight"></el-button>
</el-col>
<el-col :lg="{ span: 2, offset: 6 }" :md="2">
<el-button type="primary" @click="uploadAvatar">Submit</el-button>
</el-col>
</el-row>
</el-dialog>
</div>
</template>
<script setup>
import 'vue-cropper/dist/index.css'
import { VueCropper } from 'vue-cropper'
import useUserStore from '@/store/modules/user'
import { uploadSmallFile } from '@/utils/cloud-storage'
import dayjs from 'dayjs'
const userStore = useUserStore()
const { proxy } = getCurrentInstance()
const dialogVisible = ref(false)
const previewVisible = ref(false)
const dialogTitle = ref('Update Avatar')
const emit = defineEmits(['update:modelValue'])
const avatarOptions = reactive({
img: userStore.avatar,
autoCrop: true,
autoCropWidth: 200,
autoCropHeight: 200,
fixedBox: true,
outputType: 'png',
previews: {},
})
function openCropper() {
dialogVisible.value = true
}
function modalOpened() {
previewVisible.value = true
}
function customUpload() {
}
function rotateLeft() {
proxy.$refs.cropper.rotateLeft()
}
function rotateRight() {
proxy.$refs.cropper.rotateRight()
}
function zoomIn() {
proxy.$refs.cropper.changeScale(1)
}
function zoomOut() {
proxy.$refs.cropper.changeScale(-1)
}
function beforeFileUpload(file) {
if (file.type.indexOf('image/') == -1) {
proxy.$modal.msgError('Invalid file format. Please upload an image file with extensions like JPG or PNG.')
} else {
const reader = new FileReader()
reader.readAsDataURL(file)
reader.onload = () => {
avatarOptions.img = reader.result
}
}
}
function uploadAvatar() {
proxy.$refs.cropper.getCropBlob(data => {
const file = convertBlobToFile(data, 'avatar.png')
const currentDate = dayjs().format('YYYY/MM/DD')
uploadSmallFile(`/uploads/avatars/${currentDate}/`, file, (error, result) => {
if (error) {
proxy.$modal.error(`Upload failed: ${error}`)
} else {
dialogVisible.value = false
const imageUrl = `http://${result.Location}`
avatarOptions.img = imageUrl
emit('update:modelValue', avatarOptions.img)
userStore.avatar = avatarOptions.img
proxy.$modal.msgSuccess('Avatar updated successfully')
previewVisible.value = false
}
})
})
}
function convertBlobToFile(blob, filename) {
return new File([blob], filename, { type: blob.type })
}
function updatePreview(data) {
avatarOptions.previews = data
}
function closeDialog() {
avatarOptions.img = userStore.avatar
previewVisible.value = false
}
</script>
<style lang='scss' scoped>
.avatar-container {
position: relative;
display: inline-block;
height: 120px;
}
.avatar-preview {
width: 120px;
height: 120px;
border-radius: 50%;
object-fit: cover;
}
.avatar-container:hover:after {
content: "+";
position: absolute;
left: 0;
right: 0;
top: 0;
bottom: 0;
color: #eee;
background: rgba(0, 0, 0, 0.5);
font-size: 24px;
font-style: normal;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
cursor: pointer;
line-height: 110px;
border-radius: 50%;
}
.avatar-preview-container {
width: 200px;
height: 200px;
margin: 0 auto;
overflow: hidden;
border-radius: 50%;
border: 1px solid #ccc;
}
</style>