【超级详细】Vue3项目上传文件到七牛云的详细笔记

概述

继上一篇笔记介绍如何绑定七牛云的域名之后,这篇笔记主要介绍了如何在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前端发起请求、获取七牛云上传凭证、上传文件到七牛云的过程。整个过程包括了用户信息展示、图片裁剪、文件上传等功能,适用于大多数需要文件上传功能的场景。


本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:/a/946339.html

如若内容造成侵权/违法违规/事实不符,请联系我们进行投诉反馈qq邮箱809451989@qq.com,一经查实,立即删除!

相关文章

ROS2+OpenCV综合应用--10. AprilTag标签码追踪

1. 简介 apriltag标签码追踪是在apriltag标签码识别的基础上&#xff0c;增加了小车摄像头云台运动的功能&#xff0c;摄像头会保持标签码在视觉中间而运动&#xff0c;根据这一特性&#xff0c;从而实现标签码追踪功能。 2. 启动 2.1 程序启动前的准备 本次apriltag标签码使…

mysql乱码、mysql数据中文问号

网上排出此错误方法的很多&#xff0c;但是 都不简洁&#xff0c;找不到根本原因 主要排查两点&#xff1a; 1.代码中jdbc链接的编码规则 urljdbc:mysql://localhost:3306/title?useUnicodetrue&amp;characterEncodingutf8 将characterEncoding设置为utf8 2.设置mysq…

Presto-简单了解-230403

presto是什么了解一下&#xff1a; 秒级查询引擎&#xff08;不做存储&#xff09;&#xff0c;GB-PB级不依赖于yarn&#xff0c;有自己的资源管理和执行计划支持多种数据源&#xff1a;hive、redis、kafka presto架构 presto优缺点 presto优点 内存到内存的传输&#xff0…

openGauss连接是报org.opengauss.util.PSQLException: 尝试连线已失败

安装好高斯数据库后然后用java连接时报如下错误: 解决方法: 在openGauss数据库的安装路径下/opt/opengauss/data/single_node&#xff08;这个路径根据自己实际情况变化&#xff09;有个pg_hba.conf文件&#xff0c;修改里面host内容如下&#xff0c;我这里设置的是所有ip都能…

mybatis-plus自动填充时间的配置类实现

mybatis-plus自动填充时间的配置类实现 在实际操作过程中&#xff0c;我们并不希望创建时间、修改时间这些来手动进行&#xff0c;而是希望通过自动化来完成&#xff0c;而mybatis-plus则也提供了自动填充功能来实现这一操作&#xff0c;接下来&#xff0c;就来了解一下mybatis…

【Java项目】基于SpringBoot的【人职匹配推荐系统】

【Java项目】基于SpringBoot的【人职匹配推荐系统】 技术简介&#xff1a;本系统使用采用B/S架构、Spring Boot框架、MYSQL数据库进行开发设计。 系统简介&#xff1a;人职匹配推荐系统分为管理员和用户、企业三个权限子模块。 管理员所能使用的功能主要有&#xff1a;首页、个…

Flutter 调试环境下浏览器网络请求跨域问题解决方案

本篇文章主要讲解&#xff0c;Flutter调试环境情况下&#xff0c;浏览器调试报错跨域问题的解决方法&#xff0c;通过本篇文章你可以快速掌握Flutter调试环境情况下的跨域问题。 日期&#xff1a;2024年12月28日 作者&#xff1a;任聪聪 报错现象&#xff1a; 报文信息&#xf…

【每日学点鸿蒙知识】导入cardEmulation、自定义装饰器、CallState状态码顺序、kv配置、签名文件配置

1、HarmonyOS 无法导入cardEmulation&#xff1f; 在工程entry mudule里的index.ets文件里导入cardEmulation失败 可以按照下面方式添加SystemCapability&#xff1b;在src/main/syscap.json(此文件需要手动创建&#xff09;中添加如下内容 {"devices": {"gen…

ArcGIS JSAPI 高级教程 - 通过RenderNode实现视频融合效果(不借助三方工具)

ArcGIS JSAPI 高级教程 - 通过RenderNode实现视频融合效果&#xff08;不借助三方工具&#xff09; 核心代码完整代码在线示例 地球中展示视频可以通过替换纹理的方式实现&#xff0c;但是随着摄像头和无人机的流行&#xff0c;需要视频和场景深度融合&#xff0c;简单的实现方…

【大模型实战篇】LLaMA Factory微调ChatGLM-4-9B模型

1. 背景介绍 虽然现在大模型微调的文章很多&#xff0c;但纸上得来终觉浅&#xff0c;大模型微调的体感还是需要自己亲自上手实操过&#xff0c;才能有一些自己的感悟和直觉。这次我们选择使用llama_factory来微调chatglm-4-9B大模型。 之前微调我们是用两块3090GPU显卡&…

微信流量主挑战:三天25用户!功能未完善?(新纪元4)

&#x1f389;【小程序上线第三天&#xff01;突破25用户大关&#xff01;】&#x1f389; 嘿&#xff0c;大家好&#xff01;今天是我们小程序上线的第三天&#xff0c;我们的用户量已经突破了25个&#xff01;昨天还是16个&#xff0c;今天一觉醒来竟然有25个&#xff01;这涨…

【工具变量】国际消费中心城市DID数据(2007年-2023年)

数据简介 国际消费中心城市的定位是一个国家乃至全球消费市场消费资源的集中地和关键枢纽&#xff0c;该城市特质不单顺应我国对外交流与开放的不断扩大的趋势&#xff0c;其培育和建设国际消费中心城市的一大意义在于&#xff0c;以地区地域资源中心定位&#xff0c;来推动周围…

如何修复 WordPress 中的“Error establishing a database connection”问题

如何修复 WordPress 中的“Error establishing a database connection”问题 在使用 WordPress 建站时&#xff0c;如果你看到“Error establishing a database connection”的提示&#xff0c;不要慌张。这通常意味着网站无法连接到数据库&#xff0c;因此无法显示内容。下面…

streamlit、shiny、gradio、fastapi四个web APP平台体验

streamlit、shiny、gradio、fastapi四个web APP平台体验 经常被问的问题就是&#xff1a;web APP平台哪个好&#xff1f;该用哪个&#xff1f;刚开始只有用streamlit和shiny&#xff0c;最近体验了一下gradio和fastapi&#xff0c;今天根据自己的体会尝试着回答一下。 使用R语…

http报头解析

http报文 http报文主要有两类是常见的&#xff0c;第一类是请求报文&#xff0c;第二类是响应报文&#xff0c;每个报头除了第一行&#xff0c;都是采用键值对进行传输数据&#xff0c;请求报文的第一行主要包括http方法&#xff08;GET&#xff0c;PUT&#xff0c; POST&#…

Qwen-Agent

文章目录 一、关于 Qwen-Agent更新准备&#xff1a;模型服务免责声明 二、安装三、快速开发步骤 1&#xff1a;添加自定义工具步骤 2&#xff1a;配置 LLM步骤 3&#xff1a;创建智能体步骤 4&#xff1a;运行智能体 四、FAQ1、支持函数调用&#xff08;也称为工具调用&#xf…

flux文生图模型实践

flux文生图模型实践 flyfish https://github.com/black-forest-labs/flux Black Forest Labs发布FLUX.1 Tools&#xff0c;这是一套模型全家桶&#xff0c;旨在为FLUX.1基础文本转图像模型添加控制和可操纵性&#xff0c;从而实现对真实图像和生成图像的修改和重新创建。FLU…

【ETCD】【实操篇(十九)】ETCD基准测试实战

目录 1. 设定性能基准要求2. 使用基准测试工具基准测试命令 3. 测试不同的负载和场景4. 监控集群性能5. 评估硬件和网络的影响6. 对比性能基准7. 负载均衡和容错能力测试8. 优化与调优9. 测试在高负载下的表现总结 1. 设定性能基准要求 首先&#xff0c;明确集群性能的目标&am…

Docker Compose 构建 EMQX 集群 实现mqqt 和websocket

EMQX 集群化管理mqqt真香 目录 #目录 /usr/emqx 容器构建 vim docker-compose.yml version: 3services:emqx1:image: emqx:5.8.3container_name: emqx1environment:- "EMQX_NODE_NAMEemqxnode1.emqx.io"- "EMQX_CLUSTER__DISCOVERY_STRATEGYstatic"- …

【Cesium】三、实现开场动画效果

文章目录 实现效果实现方法实现代码组件化 实现效果 实现方法 Cesium官方提供了Camera的flyTo方法实现了飞向目的地的动画效果。 官方API&#xff1a;传送门 这里只需要用到目的地&#xff08;destination&#xff09;和持续时间&#xff08;duration&#xff09;这两个参数…