概述
继上一篇笔记介绍如何绑定七牛云的域名之后,这篇笔记主要介绍了如何在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-js
const Qiniu = await import("qiniu-js");
const config = {
useCdnDomain: true,
region: 'z2',
domain: domain, //配置好的七牛云域名
chunkSize: 100, //每个分片的大小,单位mb,默认值3
forceDirect: 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-js
const Qiniu = await import("qiniu-js");
const config = {
useCdnDomain: true,
region: 'z2',
domain: domain, //配置好的七牛云域名
chunkSize: 100, //每个分片的大小,单位mb,默认值3
forceDirect: 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:
@staticmethod
async def upload(file_name: str) -> dict:
""" 生成七牛云上传凭证 """
try:
logger.info(f'生成七牛云上传凭证....')
result = upload_file(file_name)
logger.info(f"七牛云上传凭证生成成功--> {result['token'][:12] + '*'*20}")
return result
except 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前端发起请求、获取七牛云上传凭证、上传文件到七牛云的过程。整个过程包括了用户信息展示、图片裁剪、文件上传等功能,适用于大多数需要文件上传功能的场景。