网站首页 > 技术文章 正文
最近在开发新版博客网站时,有几个页面需要使用图片上传功能。整个项目前端基于vue3的element-plue和vue-cropper组件库封装一个图片上传组件,后端使Django REST framework开发api接口,存储使用七牛对象存储,以及腾讯CDN加速,总结了完整的前后端代码以及运维配置,以供大家参考。
一、流程与思路分析
1. 整体流程图
2. 流程分析
用户图片上传以及显示整个过程可分为以下几个阶段:
- 页面加载阶段
当用户访问https://www.cuiliangblog.cn/applyLink时,前端nginx服务器接收请求(如果配置了CDN,DNS会智能解析到CDN节点处理请求),返回页面数据给用户,浏览器加载并显示页面
- 用户上传图片阶段
用户选择好本地图片文件,点击开始上传操作后,浏览器向后端API接口发送请求,获取此次上传操作的token
API后端接收到请求后,使用七牛SDK请求七牛云存储服务,获取上传token后将token返回给客户端
客户端使用token上传文件至七牛对象存储服务,上传成功后,七牛存储返回客户端资源的URL地址给客户端
- 浏览器请求图片资源阶段
客户端根据图片URL地址请求CDN服务,CDN节点发现没有找到资源后回源至七牛对象存储服务,获取文件资源成功后缓存至CDN并返回给客户端
- 用户提交表单阶段
用户提交表单,表单内容中包含资源的URL地址请求后端API接口
后端API接口保存图片资源URL地址
3. 开发需求分析
本案例使用如今最流行的前后端分离开发模式。
- 前端使用vue3开发,主要实现用户选择本地图片后裁剪成文件blob,以及将文件流和token直接请求对象存储服务,实现文件上传两个功能。
- 后端使python开发,借助Django REST framework框架开发api接口。安装七牛对象存储的sdk,通过请求七牛服务,获取本次操作的token,并返回给前端。
4. 运维配置分析
本案例使用主流的企业网站项目配置,使用公有云的OSS对象存储服务、CDN内容分发网络以及DNS域名解析。此处选用七牛云对象存储和腾讯云CDN以及阿里云的域名解析,其他公有云厂商产品名称和配置项可能略有差异,但基本原理都是一样的,操作步骤也并无差别。
没有注册的小伙伴们可以使用以下链接进行注册
- 七牛云:https://s.qiniu.com/VVfMnq
- 阿里云:https://www.aliyun.com/1111/new?userCode=gs1gd34d
- 腾讯云:https://curl.qcloud.com/yXdJdtu9
二、对象存储配置
此处使用七牛对象存储,有10G免费空间,对于一般小业务场景完全够用。
1. 创建云存储空间
登录七牛云——>进入控制台——>点击对象存储——>然后点击新建空间
填写表单完成存储空间的创建,完后后七牛云会自动为我分配一个测试域名,这样我们就可以使用这个域名进行上传/下载文件了。
需要注意的是:测试域名只能使用30天!!并且测试域名只能使用HTTP协议,不支持HTTPS协议
2. 对象存储服务绑定域名
因为我已经购买过域名cuiliangblog.cn。所以接下来绑定oss.cuiliangblog.cn给这个存储空间即可。需要注意的是,虽然七牛云的对象存储服务免费,但是CDN加速服务是收费的,我已经够买过腾讯CDN服务,所以此处配置了自定义源站域名,大家可以按照自己的实际情况选择最合适的配置。
- 需要注意的是如果使用第三方CDN服务,记住这个默认分配的CNAME,CDN配置回源策略时,填写这个CNAME。
三、CDN配置
1. 添加CDN加速域名
登录腾讯云——>控制台——>CDN内容分发网络——>域名管理——>添加域名
2. 配置回源及缓存等策略
此处以我的oss.cuiliangblog.cn对象存储域名举例,其中回源策略填写七牛云对象存储的CNAME。
四、DNS配置
1. 添加域名解析记录
登录阿里云——>控制台——>域名——>解析
- 新增一条oss.cuiliangblog.cn的CNAME域名解析记录,记录值填写腾讯云CDN的CNAME值
2. 访问验证
至此,存储服务和CDN以及DNS配置已全部完成,接下来做一个简单的测试
- 七牛云——>控制台——>对象存储——>空间管理——>文件管理——>上传文件,随便选择一张图片资源上传
- 随便上传一张图片后,点击返回,查看图片资源外链
- 使用浏览器访问验证域名解析是否正常
经过访问测试,上传图片后生成的外链可以正常打开访问,且远程地址为腾讯云CDN加速节点。到这儿,运维的工作已经完成了,接下来角色转换,现在是一位专业的后端开发工程师。
五、后端-token接口开发
1. 后端功能模块概述
要想使用七牛的对象存储服务上传文件,就需要在后端通过七牛SDK生成的一个安全凭证,只有客户端拿着这个上传凭证上传文件才是有效的,否则七牛服务器是不接受的。七牛云的开发者中心提供了很多版本的SDK,例如Go,JavaScript,PHP,Python,Node.js,Ruby,C#,C/C++等等,这里是我使用的是python的SDK,详见开发者文档:https://developer.qiniu.com/kodo/1242/python
2. 获取accessKey和secretKey
通过查看开发者文档可知,调用SDK需要传入bucket、accessKey、secretKey三个参数
bucket的值就是存储空间的名称,accessKey和secretKey可以将鼠标悬浮在右上角的头像上然后点击密钥管理,然后创新密钥
3. DRF项目相关功能代码实现
- 安装SDK
pip install qiniu
- setting.py中存放密钥信息
# 七牛OSS存储配置
QINIU_AK = 'XXXXXXXXXXXXX'
QINIU_SK = 'XXXXXXXXXXXXX'
QINIU_BUCKET = 'cuiliangoss'
QINIU_DOMAIN = 'https://oss.cuiliangblog.cn/'
- 配置请求token的API接口路由(urls.py)
from django.urls import path
from rest_framework import routers
from public import views
app_name = "public"
urlpatterns = [
path('qiniuToken/', views.QiniuTokenAPIView.as_view()),
# 获取七牛上传token
…………
]
router = routers.DefaultRouter()
urlpatterns += router.urls
- 编写视图函数(views.py),因为此处仅处理简单的响应,不涉及到模型操作,直接使用一级视图即可
from rest_framework import status
from rest_framework.response import Response
from rest_framework.views import APIView
from qiniu import Auth
from django.conf import settings
class QiniuTokenAPIView(APIView):
"""
获取七牛上传文件token
"""
def get(request):
q = Auth(settings.QINIU_AK, settings.QINIU_SK)
token = q.upload_token(settings.QINIU_BUCKET)
return Response({'token': token, 'domain': settings.QINIU_DOMAIN}, status=status.HTTP_200_OK)
- 使用API接口工具访问测试
至此,后端API接口开发完成,短短几行代码,轻松而愉快的完成了后端的开发。接来下才是整个项目最核心的部分,苦逼的前端工程师上岗了。
六、前端-上传组件开发
1. 上传组件分析
七牛对象存储支持多种多样的类型文件上传,虽然官方提供了详细的demo示例,但是在实际开发使用过程中,为了便于多个不同项目的移植和以及vue组件调用,因此将其封装为js的模块,当需要调用使用七牛的对象存储服务上传文件时,只需要传入上传文件的路径和文件对象即可。函数在执行时,先请求后端API接口,获取本次上传文件的token和domain,并提取文件名加入时间戳,避免同一时间传入多张图片导致文件名冲突,最后调用七牛JavaScript-SDK实现文件上传,并返回成功上传的文件URL地址。详细说明请参考官方文档:https://developer.qiniu.com/kodo/1283/javascript
2. 上传组件代码实现
- API请求封装,具体请参考以前发布的文章https://www.cuiliangblog.cn/detail/article/12
import index from './index'
// 获取七牛图片上传token
export function getQiNiuToken() {
return index.get('public/qiniuToken/')
}
- 七牛文件上传模块
import * as qiniu from "qiniu-js";
import {getQiNiuToken} from "@/api/public";
function qiniuUpload() { //file是选择的文件对象
const upload = (dir, file) => {
return new Promise((resolve, reject) => {
getQiNiuToken().then((response) => {
let domain = response.domain
let token = response.token
let key = dir + '/' + file.name.substring(0, file.name.lastIndexOf('.')) + '-' + new Date().getTime()
+ file.name.substring(file.name.lastIndexOf('.'))
let config = {
useCdnDomain: true, //表示是否使用 cdn 加速域名,为布尔值,true 表示使用,默认为 false。
region: qiniu.region.z1 // 根据具体提示修改上传地区,当为 null 或 undefined 时,自动分析上传域名区域
}
let putExtra = {
fname: "", //文件原文件名
params: {}, //用来放置自定义变量
mimeType: null //用来限制上传文件类型,为 null 时表示不对文件类型限制;限制类型放到数组里: ["image/png", "image/jpeg", "image/gif"]
};
const observable = qiniu.upload(file, key, token, putExtra, config)
observable.subscribe({
next: (result) => {
//主要用来展示进度
console.log(result)
},
error: (error) => {
//上传错误后触发
console.log(error);
reject(error)
},
complete: (result) => {
//上传成功后触发。包含文件地址。
let url = domain + result.key
// console.log(url)
resolve(url)
},
});
}).catch(response => {
//发生错误时执行的代码
console.log(response)
});
})
}
return {
upload
}
}
export default qiniuUpload
七、前端-裁剪组件开发
1. 裁剪模块分析
用户上传图片并镜像预览裁剪操作在多个页面中都会使用到,因此非常有必要将它封装成一个公共的子组件。其他页面使用这个组件时,图片上传地址、图片宽度、图片高度都不尽相同。因此将这个三个值设为子组件的参数变量,当用户完成图片裁剪后,点击上传时,调用上传组件,并给父组件传递success事件,并包含最终图片的URL地址参数。
2. 裁剪组件代码实现
裁剪组件基于element-plus(参考地址:https://github.com/element-plus/element-plus)和vue-cropper(参考地址:https://github.com/xyxiao001/vue-cropper)二次封装实现
- 用户图片裁剪组件(UploadImg.vue)
<template>
<div>
<el-upload accept=".jpg,.jpeg,.png"
action="./"
:auto-upload="false"
:on-change="uploadChange"
:show-file-list="false"
>
<el-button class="upload-btn">
<MyIcon class="upload-icon" type="icon-upload-img"/>
<p>选择图片</p>
</el-button>
</el-upload>
<el-dialog title="图片裁剪" v-model="showCopper" append-to-body center>
<div class="cropper" v-loading="loading" element-loading-text="图片上传中...">
<span class="cropper-area">
<vueCropper
ref="cropper"
:img="cropImg"
:autoCrop="true"
:autoCropWidth="props.width"
:autoCropHeight="props.height"
:fixedNumber="[props.width/props.height,1]"
:fixed="true"
@realTime="realTime"
></vueCropper>
</span>
<span class="preview-area">
<p>图片预览</p>
<div class="show-preview">
<div :style="previews.div" class="preview">
<img :src="previews.url" :style="previews.img">
</div>
</div>
</span>
</div>
<template #footer>
<el-button size="medium" type="success">
<label class="pointer" for="uploads">更换图片</label>
</el-button>
<input type="file" id="uploads" style="position:absolute; clip:rect(0 0 0 0);"
accept="image/png, image/jpeg, image/jpg" @change="uploadChange($event)">
<el-button-group class="cropper-btn-group">
<el-button size="medium" type="primary" plain @click="changeScale(1)">
<MyIcon type="icon-amplification"/>
</el-button>
<el-button size="medium" type="primary" plain @click="changeScale(-1)">
<MyIcon type="icon-narrow"/>
</el-button>
<el-button size="medium" type="primary" plain @click="changeReset()">
<MyIcon type="icon-reset"/>
</el-button>
<el-button size="medium" type="primary" plain @click="changeRotate(1)">
<MyIcon type="icon-clockwise-sense"/>
</el-button>
<el-button size="medium" type="primary" plain @click="changeRotate(-1)">
<MyIcon type="icon-clockwise-dirction"/>
</el-button>
</el-button-group>
<el-button size="medium" @click="showCopper=false">取 消</el-button>
<el-button type="primary" @click="confirmFn" size="medium">确 定</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import {reactive, ref} from 'vue'
import icon from "@/utils/icon";
import timeFormat from "@/utils/timeFormat";
import 'vue-cropper/dist/index.css'
import {VueCropper} from "vue-cropper";
import qiniuUpload from "@/utils/qiniuUpload";
import {ElMessage} from 'element-plus'
let {MyIcon} = icon()
// 格式化处理时间
let {timeFile} = timeFormat()
// 七牛图片上传
let {upload} = qiniuUpload()
const props = defineProps({
// 图片宽度
width: {
type: Number,
required: false,
default: 200
},
// 图片高度
height: {
type: Number,
required: false,
default: 200
},
// 图片保存目录
dir: {
type: String,
required: true,
default: 'upload'
}
})
// 定义事件(子组件向父组件传参)
const emit = defineEmits(['saveImg']);
// 图像裁剪组件对象
const cropper = ref(null);
// 裁剪后的图片文件
const cropImg = ref('');
// 图片裁剪对话框是否显示
const showCopper = ref(false);
// 文件上传组件选取图片事件
const uploadChange = (file) => {
let fileObj
if ('raw' in file) {
console.log("element对象")
fileObj = file.raw
} else {
console.log("原生对象")
fileObj = file.target.files[0]
}
const reader = new FileReader();
reader.onload = (event) => {
cropImg.value = event.target.result;
};
reader.readAsDataURL(fileObj)
showCopper.value = true;
}
// 图片裁剪预览数据
const previews = reactive({})
// 图片裁剪预览事件
const realTime = (data) => {
Object.assign(previews, data)
}
// 图片裁剪缩放事件
const changeScale = (num) => {
num = num || 1
cropper.value.changeScale(num)
}
// 图片裁剪旋转事件
const changeRotate = (num) => {
if (num === 1) {
cropper.value.rotateLeft()
} else {
cropper.value.rotateRight()
}
}
// 图片裁剪重置事件
const changeReset = () => {
cropper.value.refresh()
}
// 文件上传动画状态
const loading = ref(false)
// 图片裁剪完成上传事件
const confirmFn = () => {
// 获取blob对象
cropper.value.getCropBlob(blobData => {
console.log(blobData)
loading.value = true
//blob转file
const file = new File([blobData], timeFile(Date.now()) + '.jpg', {type: blobData.type});
console.log(file)
upload(props.dir, file).then((response) => {
console.log(response)
ElMessage({
message: '图片上传成功!',
type: 'success',
})
emit('saveImg', response)
showCopper.value = false
loading.value = false
}).catch(response => {
//发生错误时执行的代码
console.log(response)
ElMessage.error('图片上传失败!')
loading.value = false
});
})
}
</script>
<style scoped lang="scss">
.upload-btn {
.upload-icon {
font-size: 24px;
color: $color-text-secondary;
vertical-align: -7 px !important;
margin-right: 5px;
}
p {
display: inline-block;
vertical-align: 4px;
}
}
.cropper {
display: flex;
height: 50vh;
.cropper-area {
flex: 2;
}
.preview-area {
flex: 1;
margin-left: 20px;
p {
text-align: center;
margin-bottom: 20px;
}
.show-preview {
flex: 1;
-webkit-flex: 1;
display: flex;
display: -webkit-flex;
justify-content: center;
-webkit-justify-content: center;
.preview {
overflow: hidden;
border-radius: 50%;
border: 1px solid #cccccc;
background: #cccccc;
}
}
}
}
.cropper-btn-group {
margin: 0 40px;
.anticon {
font-size: 18px;
}
}
</style>
- 其他vue页面调用图片上传组件时,传入裁剪完成后图片的宽度,高度,以及文件上传目录。当完成上传操作后,图片裁剪组件会返回一个上传完成事件,并携带图片URL地址。
<template>
<div class="page">
<div class="animate__animated animate__zoomIn">
<el-card>
<template #header>
<span class="card-title no-choose"><MyIcon type="icon-form-color"/> 申请表单</span>
</template>
<div>
<el-form ref="linkFormRef" :model="linkForm" label-width="120px" :rules="rules">
<el-form-item label="网站名称" prop="name">
<el-input v-model="linkForm.name"></el-input>
</el-form-item>
<el-form-item label="网站地址" prop="url">
<el-input v-model="linkForm.url" placeholder="请输入完整地址,https://开头"></el-input>
</el-form-item>
<el-form-item label="网站简介" prop="describe">
<el-input v-model="linkForm.describe"></el-input>
</el-form-item>
<el-form-item label="网站logo" prop="logo">
<span v-if="linkForm.logo===''">
<UploadImg :width="150" :height="150" :dir="'logo'" @saveImg="saveImg"></UploadImg>
</span>
<span v-else><el-avatar :size="100" :src="linkForm.logo"></el-avatar></span>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="onSubmit">提交</el-button>
<el-button @click="reset">重置</el-button>
</el-form-item>
</el-form>
</div>
</el-card>
</div>
</div>
</template>
<script setup>
import UploadImg from "@/components/common/UploadImg.vue"
import {onMounted, reactive, ref} from "vue";
import {getSiteConfig, postLink} from "@/api/management";
import icon from "@/utils/icon";
import {ElMessage} from "element-plus";
let {MyIcon} = icon()
// 图片上传成功事件
const saveImg = (url) => {
console.log(url)
linkForm.logo = url
}
// 提交友链表单对象
const linkFormRef = ref(null)
// 提交友链表单
const linkForm = reactive({
url: '',
name: '',
describe: '',
logo: '',
})
// 表单验证规则
const rules = {
url: [{required: true, message: '请输入网站地址', trigger: 'blur',}],
name: [{required: true, message: '请输入网站名称', trigger: 'blur',}],
describe: [{required: true, message: '请输入网站描述', trigger: 'blur',}],
logo: [{required: true, message: '请上传网站logo', trigger: 'blur',}],
}
// 提交表单事件
const onSubmit = () => {
console.log('submit!')
linkFormRef.value.validate((valid) => {
if (valid) {
postLink(linkForm).then((response) => {
console.log(response)
ElMessage({
message: '友链申请提交成功,请耐心等待审核!',
type: 'success',
})
linkForm.url = ''
linkForm.name = ''
linkForm.describe = ''
linkForm.logo = ''
}).catch(response => {
//发生错误时执行的代码
console.log(response)
for (let i in response) {
ElMessage.error(response[i][0])
}
});
}
})
}
// 重置表单
const reset = () => {
linkFormRef.value.resetFields()
}
onMounted(() => {
siteConfigData()
})
</script>
<style scoped lang="scss">
.demo {
margin: 15px 0
}
.point-text {
line-height: 30px;
color: $color-text-primary;
}
</style>
八、功能验证与演示
一切准备就绪,接下来演示图片上传效果,也可查看在线地址查看效果https://www.cuiliangblog.cn/applyLink
1. 图片上传前
- 表单显示上传组件按钮
2. 图片上传中
- 选择添加本地图片并调整尺寸
3. 图片上传后
- 调用七牛上传组件SDK,完成图片上传,并返回图片URL地址
至此,整个用户图片上传流程开发完成!
更多运维开发相关文章,欢迎访问崔亮的博客 https://www.cuiliangblog.cn
- 上一篇: Vue2问题:如何在浏览器中导出word文档?4条解决方案!
- 下一篇: 你不知道的jsPDF
猜你喜欢
- 2024-11-20 抖音 Android 性能优化系列:启动优化实践
- 2024-11-20 Restic设计原理
- 2024-11-20 前端必读榜——如何在JavaScript中用SpreadJS导入/导出Excel文件
- 2024-11-20 Axios API详细介绍及使用
- 2024-11-20 镜像仓库registry命令行启动,垃圾回收和服务监听我全都要
- 2024-11-20 ElasticSearch知识day04
- 2024-11-20 详解Oracle 11g如何快速定位到lobsegment、lobindex对应的表
- 2024-11-20 前端必读:如何在 JavaScript 中使用SpreadJS导入和导出 Excel 文件
- 2024-11-20 前端文件下载的几种方式
- 2024-11-20 JavaScript奇淫技巧:20行代码,实现屏幕录像
- 标签列表
-
- content-disposition (47)
- nth-child (56)
- math.pow (44)
- 原型和原型链 (63)
- canvas mdn (36)
- css @media (49)
- promise mdn (39)
- readasdataurl (52)
- if-modified-since (49)
- css ::after (50)
- border-image-slice (40)
- flex mdn (37)
- .join (41)
- function.apply (60)
- input type number (64)
- weakmap (62)
- js arguments (45)
- js delete方法 (61)
- blob type (44)
- math.max.apply (51)
- js (44)
- firefox 3 (47)
- cssbox-sizing (52)
- js删除 (49)
- js for continue (56)
- 最新留言
-