概述
繼上一篇筆記介紹如何綁定七牛云的域名之后,這篇筆記主要介紹了如何在Vue3項目中實現文件上傳至七牛云的功能。我們將使用Cropper.js來處理圖像裁剪,并通過自定義組件和API調用來完成整個流程。
這里直接給出關鍵部分js代碼,上傳之前要先獲取一個上傳憑證,這里是由后端接口生成的具體的實現代碼在后文有體現,這里實現的功能邏輯為:將上傳的圖片轉化為base64數據之后調用uploadToQiniu 方法。
uploadToQiniu 方法實現為:
- 將base64的數據解碼出數據類型或直接使用默認類型
- 調用父組件的props.nickname設置文件名稱這一步可以自定義實現
- 調用useFileApi().upload() 接口獲取上傳憑證以及空間域名用于拼接圖片訪問url
- 將base64數據轉為二進制數據
- 動態導入qiniu-js (PS:這里用動態導入的原因是直接導入會導致父組件加載子組件的時候出現問題)
- 調用Qiniu.upload方法上傳文件
- 跟蹤上傳進度這里用一個message彈框直接顯示
const uploadToQiniu = async (base64Image) => {// 提取Base64編碼中的圖片類型const base64Data = base64Image.split(',')[0];const typeMatch = base64Data.match(/data:(.+);base64,/);const type = typeMatch ? typeMatch[1] : 'jpg';const nickname = (props.nickname || '匿名用戶').replace(/[\\/:*?"<>|]/g, '').trim().substring(0, 50);const fileName = `${nickname}-avatar-${Date.now()}.${ type }`;const result = await useFileApi().upload(fileName);if (!result || !result.data) {ElMessage.error('未能獲取上傳憑證');return;}const { token, domain } = result.data;// 將base64轉換為二進制數據const base64 = base64Image.split(',')[1];const byteCharacters = atob(base64);const byteNumbers = new Array(byteCharacters.length);for (let i = 0; i < byteCharacters.length; i++) {byteNumbers[i] = byteCharacters.charCodeAt(i);}const byteArray = new Uint8Array(byteNumbers);const file = new File([byteArray], fileName, { type: type });// 動態導入qiniu-jsconst Qiniu = await import("qiniu-js");const config = {useCdnDomain: true,region: 'z2',domain: domain, //配置好的七牛云域名chunkSize: 100, //每個分片的大小,單位mb,默認值3forceDirect: true //直傳還是斷點續傳方式,true為直傳};const putExtra = {fname: '',params: {},mimeType: type,};let uploadMessage = null; // 用于跟蹤進度消息return new Promise((resolve, reject) => {const observable = Qiniu.upload(file, fileName, token, putExtra, config);const observer = {next(res) {if (uploadMessage === null) { // 確保只顯示一次進度消息uploadMessage = ElMessage({message: '上傳中...',duration: 0,type: 'success',onClose: () => {uploadMessage = null;}});}// 更新消息內容以顯示進度,這里假設res.total === 100表示100%if (res.total === 100) {uploadMessage.close();}},error(err) {if (uploadMessage) {uploadMessage.close(); // 關閉進度消息}ElMessage.error('上傳失敗: ' + err.message);reject(err);},complete(res) {if (res.key) {if (uploadMessage) {uploadMessage.close();}resolve(`http://${domain}/${res.key}`);} else {if (uploadMessage) {uploadMessage.close();}reject(new Error('上傳成功,但未獲取到文件key或域名'));}},};observable.subscribe(observer);});
};
下面是一個完整的前后端部分代碼
技術棧
- 前端框架: Vue3
- 狀態管理: Pinia
- UI庫: Element Plus
- 圖像裁剪工具: Cropper.js
- 后端語言: Python (FastAPI)
- 存儲服務: 七牛云
實現步驟
1. 安裝依賴
首先,確保你已經安裝了必要的npm包:
npm install cropperjs element-plus pinia qiniu-js
2. 創建用戶信息組件
創建一個名為Personal.vue
的組件,用于展示和編輯用戶信息。
前端代碼比較多這里這展示關鍵部分js代碼 源碼在文末
<!-- 設置頭像組件 通過updateAvatar傳遞到父組件 通過nickname傳遞到子組件 -->
<SeePictures ref="SeePicturesRef"@updateAvatar="updateAvatar":nickname="state.personalForm.nickname"
></SeePictures>const SeePictures = defineAsyncComponent(() => import("/@/components/seePictures/index.vue"))
const SeePicturesRef = ref();// 打開裁剪彈窗
const onCropperDialogOpen = () => {nextTick(() => {SeePicturesRef.value.openDialog(state.personalForm.avatar);});
};
const save = async () => {// 保存用戶信息await useUserApi().saveOrUpdate(state.personalForm)await getUserInfo()ElMessage.success("更新成功!╰(*°▽°*)╯😍")if (state.showEditPage){state.showEditPage = !state.showEditPage}
}
const updateAvatar = async (img) => {state.personalForm.avatar = img// 跟新session中用戶信息await userStores.updateAvatar(img)save()
}
3. 創建圖片查看與裁剪組件
創建一個名為SeePictures.vue
的組件,用于顯示和裁剪圖片。
前端代碼比較多這里這展示關鍵部分js代碼 源碼在文末
const onSubmit = async () => {state.cropperImgBase64 = state.cropper.getCroppedCanvas().toDataURL('image/jpeg');try {const qiniuResult = await uploadToQiniu(state.cropperImgBase64);if (qiniuResult) {emit("updateAvatar", qiniuResult);closeDialog();}} catch (error) {console.error('上傳到七牛云失敗:', error);}// emit("updateAvatar", state.cropperImgBase64)// closeDialog();
};// 上傳到七牛云
const uploadToQiniu = async (base64Image) => {// 提取Base64編碼中的圖片類型const base64Data = base64Image.split(',')[0];const typeMatch = base64Data.match(/data:(.+);base64,/);const type = typeMatch ? typeMatch[1] : 'jpg';const nickname = (props.nickname || '匿名用戶').replace(/[\\/:*?"<>|]/g, '').trim().substring(0, 50);const fileName = `${nickname}-avatar-${Date.now()}.${ type }`;const result = await useFileApi().upload(fileName);if (!result || !result.data) {ElMessage.error('未能獲取上傳憑證');return;}const { token, domain } = result.data;// 將base64轉換為二進制數據const base64 = base64Image.split(',')[1];const byteCharacters = atob(base64);const byteNumbers = new Array(byteCharacters.length);for (let i = 0; i < byteCharacters.length; i++) {byteNumbers[i] = byteCharacters.charCodeAt(i);}const byteArray = new Uint8Array(byteNumbers);const file = new File([byteArray], fileName, { type: type });// 動態導入qiniu-jsconst Qiniu = await import("qiniu-js");const config = {useCdnDomain: true,region: 'z2',domain: domain, //配置好的七牛云域名chunkSize: 100, //每個分片的大小,單位mb,默認值3forceDirect: true //直傳還是斷點續傳方式,true為直傳};const putExtra = {fname: '',params: {},mimeType: type,};let uploadMessage = null; // 用于跟蹤進度消息return new Promise((resolve, reject) => {const observable = Qiniu.upload(file, fileName, token, putExtra, config);const observer = {next(res) {if (uploadMessage === null) { // 確保只顯示一次進度消息uploadMessage = ElMessage({message: '上傳中...',duration: 0,type: 'success',onClose: () => {uploadMessage = null;}});}// 更新消息內容以顯示進度,這里假設res.total === 100表示100%if (res.total === 100) {uploadMessage.close();}},error(err) {if (uploadMessage) {uploadMessage.close(); // 關閉進度消息}ElMessage.error('上傳失敗: ' + err.message);reject(err);},complete(res) {if (res.key) {if (uploadMessage) {uploadMessage.close();}resolve(`http://${domain}/${res.key}`);} else {if (uploadMessage) {uploadMessage.close();}reject(new Error('上傳成功,但未獲取到文件key或域名'));}},};observable.subscribe(observer);});
};
4. 前端接口代碼
在你的API模塊中添加上傳文件的方法。
import request from '/@/utils/request';/*** 文件接口*/
export function useFileApi() {return {upload: (data) => {return request({url: '/file/qiniu/token',method: 'POST',data,});}};
}
5. 后端接口代碼
編寫FastAPI后端接口以獲取七牛云上傳憑證并處理文件上傳。
@router.post('/qiniu/token', description="獲取文件上傳憑證")
async def upload(file_name: str = Body(...)):result = await FileService.upload(file_name)return partner_success(result)
class FileService:@staticmethodasync def upload(file_name: str) -> dict:""" 生成七牛云上傳憑證 """try:logger.info(f'生成七牛云上傳憑證....')result = upload_file(file_name)logger.info(f"七牛云上傳憑證生成成功--> {result['token'][:12] + '*'*20}")return resultexcept Exception as e:logger.error(f'七牛云上傳憑證生成失敗: {e}')raise ParameterError(CodeEnum.FILE_UPLOAD_FAILED)
# 上傳文件類
class UploadQiNiu:# 僅支持單文件上傳def __init__(self, config):self.access_key = config['access_key']self.secret_key = config['secret_key']self.bucket_name = config['bucket_name']self.domain = config['domain']self._q = Auth(self.access_key, self.secret_key)self._bucket = BucketManager(self._q)def get_qiniu_upload_token(self, save_file_name):""" 獲取七牛云上傳憑證 """return self._q.upload_token(self.bucket_name, save_file_name)
""" 七牛云配置 """
qiniu_config = {'access_key': config.access_key, # access_key'secret_key': config.secret_key, # secret_key'bucket_name': config.bucket_name, # 存儲空間名稱'domain': config.domain # 空間域名
}# 獲取七牛云上傳憑證
def upload_file(save_file_name: str) -> dict:try:upload_qiniu = UploadQiNiu(qiniu_config)token = upload_qiniu.get_qiniu_upload_token(save_file_name)return {'token': token,'domain': qiniu_config['domain']}except Exception as e:logger.warning(f'獲取七牛云上傳憑證失敗: {e}')raise ParameterError(CodeEnum.FILE_UPLOAD_FAILED)
Personal.vue 組件代碼
<template><div><!-- 設置頭像組件 --><SeePictures ref="SeePicturesRef" @updateAvatar="updateAvatar" :nickname="state.personalForm.nickname"></SeePictures><!-- 其他表單項 --><el-form label-width="100px"><el-form-item label="用戶名"><el-input v-model="state.personalForm.username" disabled></el-input></el-form-item><el-form-item label="昵稱"><el-input v-model="state.personalForm.nickname"></el-input></el-form-item><el-form-item label="郵箱"><el-input v-model="state.personalForm.email"></el-input></el-form-item><el-form-item label="標簽"><el-tag v-for="(tag, index) in state.personalForm.tags" :key="index" closable @close="removeTag(tag)">{{ tag }}</el-tag><el-input v-if="state.editTag" ref="UserTagInputRef" v-model="state.tagValue" size="small" style="width: 100px;"@keyup.enter.native="addTag" @blur="addTag"></el-input><el-button v-else class="button-new-tag ml-1" size="small" @click="showEditTag">+ New Tag</el-button></el-form-item><el-form-item><el-button type="primary" @click="save">保存</el-button></el-form-item></el-form></div>
</template><script setup name="personal">
import { defineAsyncComponent, nextTick, onMounted, reactive, ref } from 'vue';
import { useUserInfo } from "/@/stores/userInfo";
import { useUserApi } from "/@/api/useSystemApi/user";
import { ElMessage } from "element-plus";
import { storeToRefs } from "pinia";const SeePictures = defineAsyncComponent(() => import("/@/components/seePictures/index.vue"));
const SeePicturesRef = ref();
const UserTagInputRef = ref();// 用戶信息
const userStores = useUserInfo();
const { userInfos } = storeToRefs(userStores);// 定義變量內容
const state = reactive({personalForm: {username: '',nickname: '',avatar: '',email: '',tags: '',},editTag: false,tagValue: "",cropperImg: '',
});const getUserInfo = async () => {let { data } = await useUserApi().getUserInfoByToken();if (!data.avatar) {data.avatar = "";}state.personalForm = data;
};// 打開裁剪彈窗
const onCropperDialogOpen = () => {nextTick(() => {SeePicturesRef.value.openDialog(state.personalForm.avatar);});
};// tags
const showEditTag = () => {state.editTag = true;nextTick(() => {UserTagInputRef.value?.input.focus();});
};const removeTag = (tag) => {state.personalForm.tags.splice(state.personalForm.tags.indexOf(tag), 1);
};const addTag = () => {if (state.editTag && state.tagValue) {if (!state.personalForm.tags) state.personalForm.tags = [];state.personalForm.tags.push(state.tagValue);}state.editTag = false;state.tagValue = '';
};const save = async () => {// 保存用戶信息await useUserApi().saveOrUpdate(state.personalForm);await getUserInfo();ElMessage.success("更新成功!╰(*°▽°*)╯😍");
};const updateAvatar = async (img) => {state.personalForm.avatar = img;// 更新session中用戶信息await userStores.updateAvatar(img);save();
};onMounted(() => {getUserInfo();
});
</script><style scoped>
.button-new-tag {margin-left: 10px;height: 32px;line-height: 30px;padding-top: 0;padding-bottom: 0;
}
</style>
<template><div><el-dialog title="裁剪圖片" v-model="dialogVisible" width="80%"><div class="cropper-content"><img id="image" :src="imageUrl" alt="Source Image" /></div><template #footer><span class="dialog-footer"><el-button @click="dialogVisible = false">取消</el-button><el-button type="primary" @click="cropImage">確定</el-button></span></template></el-dialog></div>
</template><script setup>
import { ref, watch } from 'vue';
import Cropper from 'cropperjs';
import 'cropperjs/dist/cropper.min.css';const props = defineProps({nickname: String,
});const emit = defineEmits(['updateAvatar']);let imageUrl = ref('');
let dialogVisible = ref(false);
let cropper = null;watch(dialogVisible, (val) => {if (!val) {cropper.destroy();}
});const openDialog = (url) => {imageUrl.value = url;dialogVisible.value = true;nextTick(() => {cropper = new Cropper(document.getElementById('image'), {aspectRatio: 1 / 1,viewMode: 1,autoCropArea: 1,cropBoxResizable: false,});});
};const cropImage = () => {cropper.getCroppedCanvas().toBlob(async (blob) => {const formData = new FormData();formData.append('file', blob, `${props.nickname}_avatar.png`);try {const response = await fetch('/file/qiniu/upload', {method: 'POST',body: formData,});const result = await response.json();emit('updateAvatar', result.url);} catch (error) {console.error(error);}}, 'image/png');
};
</script><style scoped>
.cropper-content {max-height: 50vh;overflow-y: auto;
}#image {display: block;width: 100%;
}
</style>
總結
通過以上步驟,我們實現了從Vue3前端發起請求、獲取七牛云上傳憑證、上傳文件到七牛云的過程。整個過程包括了用戶信息展示、圖片裁剪、文件上傳等功能,適用于大多數需要文件上傳功能的場景。