目录
[01: 通用组件:input 构建方案分析](#01: 通用组件:input 构建方案分析)
[02: 通用组件:input 构建方案](#02: 通用组件:input 构建方案)
[03: 构建用户资料基础样式](#03: 构建用户资料基础样式)
[04: 用户基本资料修改方案](#04: 用户基本资料修改方案)
[05: 处理不保存时的同步问题](#05: 处理不保存时的同步问题)
[06: 头像修改方案流程分析](#06: 头像修改方案流程分析)
[07: 通用组件:Dialog 构建方案分析](#07: 通用组件:Dialog 构建方案分析)
[08: 通用组件:Dialog 构建方案](#08: 通用组件:Dialog 构建方案)
[09: 应用 Dialog 展示头像](#09: 应用 Dialog 展示头像)
[10: 头像裁剪构建方案](#10: 头像裁剪构建方案)
[11. 阿里云 OSS 与腾讯云 COS 对象存储方案分析](#11. 阿里云 OSS 与腾讯云 COS 对象存储方案分析)
[腾讯云 COS](#腾讯云 COS)
[COS SDK(COS 的包)](#COS SDK(COS 的包))
[阿里云 OSS](#阿里云 OSS)
[OSS 基础概念](#OSS 基础概念)
[使用 STS 临时访问凭证访问 OSS](#使用 STS 临时访问凭证访问 OSS)
[上传图片到 Bucket 的流程分析](#上传图片到 Bucket 的流程分析)
[配置 CORS 跨域处理](#配置 CORS 跨域处理)
[12. 使用临时凭证,上传裁剪图片到阿里云 OSS](#12. 使用临时凭证,上传裁剪图片到阿里云 OSS)
[13. 完成头像更新操作](#13. 完成头像更新操作)
[14. 登录鉴权解决方案](#14. 登录鉴权解决方案)
[15: 总结](#15: 总结)
01: 通用组件:input 构建方案分析
期望通用组件 input 至少满足 4 个功能:
-
支持单行文本输入
-
支持多行文本输入
-
通过 v-model 实现双向数据绑定
-
支持最大文本输入
根据以上功能点,可判断出 input 组件要有 3 个 prop:
-
v-model
-
type:单行 or 多行
-
max:支持最大字符数
02: 通用组件:input 构建方案
bash
- src/libs
- - input
- - - index.vue
javascript
// src/libs/input/index.vue
<template>
<div class="relative">
<input
v-if="type === TYPE_TEXT"
class="border-gray-200 dark:border-zinc-600 dark:bg-zinc-800 duration-100 dark:text-zinc-400 border-[1px] outline-0 py-0.5 px-1 text-sm rounded-sm focus:border-blue-400 w-full"
type="text"
v-model="text"
:maxlength="max"
/>
<textarea
v-if="type === TYPE_TEXTAREA"
v-model="text"
:maxlength="max"
rows="5"
class="border-gray-200 dark:border-zinc-600 dark:bg-zinc-800 duration-100 dark:text-zinc-400 border-[1px] outline-0 py-0.5 px-1 text-sm rounded-sm focus:border-blue-400 w-full"
></textarea>
<span
v-if="max"
class="absolute right-1 bottom-0.5 text-zinc-400 text-xs"
:class="{ 'text-red-700': currentNumber === parseInt(max) }"
>{{ currentNumber }} / {{ max }}</span
>
</div>
</template>
<script>
const TYPE_TEXT = 'text'
const TYPE_TEXTAREA = 'textarea'
</script>
<script setup>
import { useVModel } from '@vueuse/core'
import { computed } from 'vue'
const props = defineProps({
modelValue: {
required: true,
type: String
},
type: {
type: String,
default: TYPE_TEXT,
validator(value) {
const arr = [TYPE_TEXT, TYPE_TEXTAREA]
const result = arr.includes(value)
if (!result) {
throw new Error(`type 的值必须在可选范围内 [${arr.join('、')}]`)
}
return result
}
},
max: {
type: [String, Number]
}
})
// 事件声明
defineEmits(['update:modelValue'])
// 输入的字符
const text = useVModel(props)
// 输入的字符数
const currentNumber = computed(() => {
return text.value?.length
})
</script>
<style lang="scss" scoped></style>
03: 构建用户资料基础样式
tailwindcss 先构建移动端,再构建 PC 端更方便。
bash
- src/views
- - profile
- - - index.vue
javascript
// 路由信息
{
path: '/profile',
name: 'profile',
component: () => import('@/views/profile/index.vue'),
// 标记当前的页面只有用户登录之后才可以进入
meta: {
user: true
}
},
javascript
// src/views/profile/index.vue
<template>
<div
class="h-full bg-zinc-200 dark:bg-zinc-800 duration-400 overflow-auto xl:pt-1"
>
<div
class="relative max-w-screen-lg mx-auto bg-white dark:bg-zinc-900 duration-400 xl:rounded-sm xl:border-zinc-200 xl:dark:border-zinc-600 xl:border-[1px] xl:px-4 xl:py-2"
>
<!-- 移动端 navbar -->
<m-navbar sticky v-if="isMobileTerminal" :clickLeft="onNavbarLeftClick">
个人资料
</m-navbar>
<!-- pc 端 -->
<div v-else class="text-lg font-bold text-center mb-4 dark:text-zinc-300">
个人资料
</div>
<div class="h-full w-full px-1 pb-4 text-sm mt-2 xl:w-2/3 xl:pb-0">
<!-- 头像 -->
<div class="py-1 xl:absolute xl:right-[16%] xl:text-center">
<span
class="w-8 inline-block mb-2 font-bold text-sm dark:text-zinc-300 xl:block xl:mx-auto"
>
我的头像
</span>
<!-- 头像部分 -->
<div
class="relative w-[80px] h-[80px] group xl:cursor-pointer xl:left-[50%] xl:translate-x-[-50%]"
@click="onAvatarClick"
>
<img
v-lazy
:src="$store.getters.userInfo.avatar"
alt=""
class="rounded-[50%] w-full h-full xl:inline-block"
/>
<div
class="absolute top-0 rounded-[50%] w-full h-full bg-[rgba(0,0,0,.4)] hidden xl:group-hover:block"
>
<m-svg-icon
name="change-header-image"
class="w-2 h-2 m-auto mt-2"
></m-svg-icon>
<div
class="text-xs text-white dark:text-zinc-300 scale-90 mt-0.5"
>
点击更换头像
</div>
</div>
</div>
<!-- 隐藏域 -->
<input
v-show="false"
ref="inputFileTarget"
type="file"
accept=".png, .jpeg, .jpg, .gif"
@change="onSelectImgHandler"
/>
<p class="mt-1 text-zinc-500 dark:text-zinc-400 text-xs xl:w-10">
支持 jpg、png、jpeg 格式大小 5M 以内的图片
</p>
</div>
<!-- 用户名 -->
<div class="py-1 xl:flex xl:items-center xl:my-1">
<span class="w-8 block mb-1 font-bold dark:text-zinc-300 xl:mb-0">
用户名
</span>
<m-input
v-model="userInfo.nickname"
class="w-full"
type="text"
max="20"
></m-input>
</div>
<!-- 职位 -->
<div class="py-1 xl:flex xl:items-center xl:my-1">
<span class="w-8 block mb-1 font-bold dark:text-zinc-300 xl:mb-0">
职位
</span>
<m-input
v-model="userInfo.title"
class="w-full"
type="text"
></m-input>
</div>
<!-- 公司 -->
<div class="py-1 xl:flex xl:items-center xl:my-1">
<span class="w-8 block mb-1 font-bold dark:text-zinc-300 xl:mb-0">
公司
</span>
<m-input
v-model="userInfo.company"
class="w-full"
type="text"
></m-input>
</div>
<!-- 个人主页 -->
<div class="py-1 xl:flex xl:items-center xl:my-1">
<span class="w-8 block mb-1 font-bold dark:text-zinc-300 xl:mb-0">
个人主页
</span>
<m-input
v-model="userInfo.homePage"
class="w-full"
type="text"
></m-input>
</div>
<!-- 个人介绍 -->
<div class="py-1 xl:flex xl:my-1">
<span class="w-8 block mb-1 font-bold dark:text-zinc-300 xl:mb-0">
个人介绍
</span>
<m-input
v-model="userInfo.introduction"
class="w-full"
type="textarea"
max="50"
></m-input>
</div>
<!-- 保存修改 -->
<m-button
class="w-full mt-2 mb-4 dark:text-zinc-300 dark:bg-zinc-800 xl:w-[160px] xl:ml-[50%] xl:translate-x-[-50%]"
:loading="loading"
@click="onChangeProfile"
>
保存修改
</m-button>
<!-- 移动端退出登录 -->
<m-button
v-if="isMobileTerminal"
class="w-full dark:text-zinc-300 dark:bg-zinc-800 xl:w-[160px] xl:ml-[50%] xl:translate-x-[-50%]"
@click="onLogoutClick"
>
退出登录
</m-button>
</div>
</div>
<!-- PC 端 -->
<m-dialog v-if="!isMobileTerminal" v-model="isDialogVisible">
<change-avatar-vue
:blob="currentBolb"
@close="isDialogVisible = false"
></change-avatar-vue>
</m-dialog>
<!-- 移动端 -->
<m-popup
v-else
:class="{ 'h-screen': isDialogVisible }"
v-model="isDialogVisible"
>
<change-avatar-vue
:blob="currentBolb"
@close="isDialogVisible = false"
></change-avatar-vue>
</m-popup>
</div>
</template>
<script>
export default {
name: 'profile'
}
</script>
<script setup>
import { isMobileTerminal } from '@/utils/flexible'
import { putProfile } from '@/api/sys'
import { message, confirm } from '@/libs'
import { useRouter } from 'vue-router'
import { useStore } from 'vuex'
import { ref, watch } from 'vue'
import changeAvatarVue from './components/change-avatar.vue'
const store = useStore()
const router = useRouter()
// 隐藏域
const inputFileTarget = ref(null)
// 头像 dialog 展示
const isDialogVisible = ref(false)
// 选中的图片
const currentBolb = ref('')
/**
* 更换头像点击事件
*/
const onAvatarClick = () => {
inputFileTarget.value.click()
}
/**
* 头像选择之后的回调
*/
const onSelectImgHandler = () => {
// 获取选中的文件
const imgFile = inputFileTarget.value.files[0]
// 生成 blob 对象
const blob = URL.createObjectURL(imgFile)
// 获取选中的图片
currentBolb.value = blob
// 展示 Dialog
isDialogVisible.value = true
}
/**
* 监听 dialog 关闭
*/
watch(isDialogVisible, (val) => {
if (!val) {
// 防止 change 不重复触发
inputFileTarget.value.value = null
}
})
/**
* 数据本地的双向同步,增加一个单层深拷贝
*/
const userInfo = ref({ ...store.getters.userInfo })
// const changeStoreUserInfo = (key, value) => {
// store.commit('user/setUserInfo', {
// ...store.getters.userInfo,
// [key]: value
// })
// }
/**
* 修改个人信息
*/
const loading = ref(false)
const onChangeProfile = async () => {
loading.value = true
await putProfile(userInfo.value)
message('success', '用户信息修改成功')
// 更新 vuex
store.commit('user/setUserInfo', userInfo.value)
loading.value = false
}
/**
* 移动端后退处理
*/
const onNavbarLeftClick = () => {
// 配置跳转方式
store.commit('app/changeRouterType', 'back')
router.back()
}
/**
* 移动端:退出登录
*/
const onLogoutClick = () => {
confirm('确定要退出登录吗?').then(() => {
store.dispatch('user/logout')
})
}
</script>
<style lang="scss" scoped></style>
04: 用户基本资料修改方案
javascript
// 第一种方式:直接修改 vuex state 数据,违背 vue 理念。
<m-input v-model="$store.getters.userInfo.nickname" />
// 第二种方式:优化第一种方式。
<m-input :modelValue="$store.getters.userInfo.nickname"
@update:modelValue="changeStoreUserInfo('nickname', $event)"
/>
const changeStoreUserInfo = (key, value) => {
store.commit('user/setUserInfo', {
...store.getters.userInfo,
[key]: value
})
}
// 第三种方式:解决用户信息未提交,但提前缓存问题。推荐。
<m-input v-model="userInfo.nickname" />
/**
* 数据本地的双向同步,增加一个单层深拷贝
*/
const userInfo = ref({ ...store.getters.userInfo })
/**
* 修改个人信息
*/
const onChangeProfile = async () => {
// 发送修改请求 代码省略
// 更新 vuex
store.commit('user/setUserInfo', userInfo.value)
}
05: 处理不保存时的同步问题
讲解一下 v-model 拆解的问题。
给不理解的同学讲解一下:为什么不能用 v-model 直接绑定 vuex 中的数据?以及绑定之后会产生什么样的问题?
思路及代码在上一小节之中。
06: 头像修改方案流程分析
接下来我们需要处理头像修改的业务逻辑。
对于该功能而言,分为 PC 端和 移动端 两种情况,我们需要分别进行处理:
-
PC 端:
-
点击更换头像。
-
选择对应文件。
-
通过 Dialog 展示图片剪裁。
-
剪裁后图片上传。
-
功能完成。
-
移动端:
-
点击更换头像。
-
选择对应文件。
-
通过 popup 展示图片剪裁。
-
剪裁后图片上传。
-
功能完成。
由此可以发现,两者之间需要通过不同的组件进行裁剪展示。
因此我们后续的开发流程为:
-
通用组件:Dialog。
-
处理图片剪裁。
-
处理剪裁后上传。
07: 通用组件:Dialog 构建方案分析
首先我们来处理 Dialog 通用组件。
对于 Dialog 通用组件而言,我们可以参考 confirm 组件的构建过程。
它们两个构建方案非常相似,唯二不同的地方是:
-
Dialog 无需通过方法调用的形式展示。
-
Dialog 的内容区可以渲染任意的内容。
排除这两点之后,其余与 confirm 完全相同。
08: 通用组件:Dialog 构建方案
bash
- src/libs
- - dialog
- - - index.vue
javascript
<template>
<div>
<!-- 蒙版 -->
<transition name="fade">
<div
v-if="isVisable"
@click="close"
class="w-screen h-screen bg-zinc-900/80 z-40 fixed top-0 left-0"
></div>
</transition>
<!-- 内容 -->
<transition name="up">
<div
v-if="isVisable"
class="max-w-[80%] max-h-[80%] overflow-auto fixed top-[10%] left-[50%] translate-x-[-50%] z-50 px-2 py-1.5 rounded-sm border dark:border-zinc-600 cursor-pointer bg-white dark:bg-zinc-800 xl:min-w-[35%]"
>
<!-- 标题 -->
<div
class="text-lg font-bold text-zinc-900 dark:text-zinc-200 mb-2"
v-if="title"
>
{{ title }}
</div>
<!-- 内容 -->
<div class="text-base text-zinc-900 dark:text-zinc-200 mb-2">
<slot />
</div>
<!-- 按钮 -->
<div class="flex justify-end" v-if="cancelHandler || confirmHandler">
<m-button type="info" class="mr-2" @click="onCancelClick">{{
cancelText
}}</m-button>
<m-button type="primary" @click="onConfirmClick">{{
confirmText
}}</m-button>
</div>
</div>
</transition>
</div>
</template>
<script setup>
import { useVModel } from '@vueuse/core'
const props = defineProps({
// 控制开关
modelValue: {
type: Boolean,
required: true
},
// 标题
title: {
type: String
},
// 取消按钮文本
cancelText: {
type: String,
default: '取消'
},
// 确定按钮文本
confirmText: {
type: String,
default: '确定'
},
// 取消按钮点击事件
cancelHandler: {
type: Function
},
// 确定按钮点击事件
confirmHandler: {
type: Function
},
// 关闭的回调
close: {
type: Function
}
})
defineEmits(['update:modelValue'])
// 控制显示处理
const isVisable = useVModel(props)
/**
* 取消按钮点击事件
*/
const onCancelClick = () => {
if (props.cancelHandler) {
props.cancelHandler()
}
close()
}
/**
* 确定按钮点击事件
*/
const onConfirmClick = () => {
if (props.confirmHandler) {
props.confirmHandler()
}
close()
}
const close = () => {
isVisable.value = false
if (props.close) {
props.close()
}
}
</script>
<style lang="scss" scoped>
.fade-enter-active,
.fade-leave-active {
transition: all 0.3s;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
.up-enter-active,
.up-leave-active {
transition: all 0.3s;
}
.up-enter-from,
.up-leave-to {
opacity: 0;
transform: translate3d(-50%, 100px, 0);
}
</style>
09: 应用 Dialog 展示头像
javascript
// src/views/profile/index.vue
<template>
<img :src="currentBolb" />
</template>
<script setup>
// 选中的图片
const currentBolb = ref('')
/**
* 头像选择之后的回调
*/
const onSelectImgHandler = () => {
// 获取选中的文件
const imgFile = inputFileTarget.value.files[0]
// 生成 blob 对象
const blob = URL.createObjectURL(imgFile)
// 获取选中的图片
currentBolb.value = blob
// 展示 Dialog
isDialogVisible.value = true
}
/**
* 当 input file 两次选择文件,是同一个的时候,change 的回调不会被再次触发。
* 想要解决这个问题,只需要在每次选择的图片不再被使用之后,清空掉 input file 的 value。
* 监听 dialog 关闭
*/
watch(isDialogVisible, (val) => {
if (!val) {
// 防止 change 不重复触发
inputFileTarget.value.value = null
}
})
</script>
bash
- src/views/profile
- - components
- - - change-avatar.vue
javascript
// src/views/profile/components/change-avatar.vue
<template>
<div class="overflow-auto relative flex flex-col items-center">
<m-svg-icon
v-if="isMobileTerminal"
name="close"
class="w-3 h-3 p-0.5 m-1 ml-auto"
fillClass="fill-zinc-900 dark:fill-zinc-200 "
@click="close"
></m-svg-icon>
<img class="" ref="imageTarget" :src="blob" />
<m-button
class="mt-4 w-[80%] xl:w-1/2"
:loading="loading"
@click="onConfirmClick"
>
确定
</m-button>
</div>
</template>
<script>
const EMITS_CLOSE = 'close'
// 移动端配置对象
const mobileOptions = {
// 将裁剪框限制在画布的大小
viewMode: 1,
// 移动画布,裁剪框不动
dragMode: 'move',
// 裁剪框固定纵横比:1:1
aspectRatio: 1,
// 裁剪框不可移动
cropBoxMovable: false,
// 不可调整裁剪框大小
cropBoxResizable: false
}
// PC 端配置对象
const pcOptions = {
// 裁剪框固定纵横比:1:1
aspectRatio: 1
}
</script>
<script setup>
import { isMobileTerminal } from '@/utils/flexible'
import Cropper from 'cropperjs'
import 'cropperjs/dist/cropper.css'
import { ref, onMounted } from 'vue'
import { getOSSClient } from '@/utils/sts'
import { message } from '@/libs'
import { useStore } from 'vuex'
import { putProfile } from '@/api/sys'
defineProps({
blob: {
type: String,
required: true
}
})
const emits = defineEmits([EMITS_CLOSE])
/**
* 图片裁剪处理
*/
const imageTarget = ref(null)
let cropper = null
onMounted(() => {
/**
* 接收两个参数:
* 1. 需要裁剪的图片 DOM
* 2. options 配置对象
*/
cropper = new Cropper(
imageTarget.value,
isMobileTerminal.value ? mobileOptions : pcOptions
)
})
/**
* 确定按钮点击事件
*/
const loading = ref(false)
const onConfirmClick = () => {
loading.value = true
// 获取裁剪后的图片
cropper.getCroppedCanvas().toBlob((blob) => {
// 裁剪后的 blob 地址
// console.log(URL.createObjectURL(blob))
putObjectToOSS(blob)
})
}
/**
* 进行 OSS 上传
*/
let ossClient = null
let store = useStore()
const putObjectToOSS = async (file) => {
if (!ossClient) {
ossClient = await getOSSClient()
}
try {
// 因为当前凭证只具备 images 文件夹下的访问权限,所以图片需要上传到 images/xxx.xx 。否则你将得到一个 《AccessDeniedError: You have no right to access this object because of bucket acl.》 的错误
const fileTypeArr = file.type.split('/')
const fileName = `${store.getters.userInfo.username}/${Date.now()}.${
fileTypeArr[fileTypeArr.length - 1]
}`
// 文件存放路径,文件
const res = await ossClient.put(`images/${fileName}`, file)
// 通知服务器
onChangeProfile(res.url)
} catch (e) {
message('error', e)
}
}
/**
* 上传新头像到服务器
*/
const onChangeProfile = async (avatar) => {
// 更新本地数据
store.commit('user/setUserInfo', {
...store.getters.userInfo,
avatar
})
// 更新服务器数据
await putProfile(store.getters.userInfo)
// 通知用户
message('success', '用户头像修改成功')
// 关闭 loading
loading.value = false
// 关闭 dialog
close()
}
/**
* 关闭事件
*/
const close = () => {
emits(EMITS_CLOSE)
}
</script>
<style lang="scss" scoped></style>
10: 头像裁剪构建方案
接下来我们需要在 src/views/profile/components/change-avatar.vue 中处理对应的图片裁剪功能。
想要处理图片裁剪,我们需要使用到 cropperjs,它是一个 Javascript 的库,同时支持 PC 端 和 移动端。
目前 cropperjs 的最新发布版本为 1.6.2,V2 级别的版本还是 RC 阶段,所以我们还是使用它的 V1 版本。
- 安装 cropperjs
bash
npm install cropperjs@1.5.12 --save
- 在 src/views/profile/components/change-acatar.vue 中进行导入
javascript
import Cropper from 'cropperjs'
import 'cropperjs/dist/cropper.css'
- 使用 new Cropper 进行初始化,区分 PC端 和 移动端:所有配置项
javascript
// 移动端配置对象
const mobileOptions = {
// 将裁剪框限制在画布的大小
viewMode: 1,
// 移动画布,裁剪框不动
dragMode: 'move',
// 裁剪框固定纵横比:1:1
aspectRatio: 1,
// 裁剪框不可移动
cropBoxMovable: false,
// 不可调整裁剪框大小
cropBoxResizable: false
}
// PC 端配置对象
const pcOptions = {
// 裁剪框固定纵横比:1:1
aspectRatio: 1
}
javascript
/**
* 图片裁剪处理
*/
const imageTarget = ref(null)
let cropper = null
onMounted(() => {
/**
* 接收两个参数:
* 1. 需要裁剪的图片 DOM
* 2. options 配置对象
*/
cropper = new Cropper(
imageTarget.value,
isMobileTerminal.value ? mobileOptions : pcOptions
)
})
javascript
/**
* 确定按钮点击事件
*/
const loading = ref(false)
const onConfirmClick = () => {
loading.value = true
// 获取裁剪后的图片
cropper.getCroppedCanvas().toBlob((blob) => {
// 裁剪后的 blob 地址
// console.log(URL.createObjectURL(blob))
putObjectToOSS(blob)
})
}
11. 阿里云 OSS 与腾讯云 COS 对象存储方案分析
当图片裁剪处理完成之后,接下来我们就可以处理裁剪之后的图片上传了。
通常情况下,在企业开发中,图片或文件的管理都会通过 对象存储 的方案进行。目前国内常见的对象存储云方案主要有两个平台:
腾讯云 COS
腾讯云 COS 目前提供了 实名认证赠送 6个月 对象存储的服务 ,点击跳转
这个服务对大家而言是非常好的一个练习服务,大家可以直接使用该服务来实现 裁剪图片上传到腾讯云。
以下为腾讯云 COS 使用流程:
-
注册 腾讯云 账号,并完成 实名认证。
-
选择免费产品。
-
选择腾讯云 COS。
-
点击立即开通。
-
创建存储桶。
-
点击创建。
-
选择 公有读、私有写。
-
一路下一步,创建即可。
此时你可以得到对应的存储桶,所有的文件都会被上传到该存储桶之中。
接下来就可以按照以下步骤进行图片上传:
COS SDK(COS 的包)
- 下载依赖包:
bash
npm i cos-js-sdk-v5 --save
- 构建 cos 实例:初始化 cos 对象参数
|-----------|-----------------------------------------|
| 名称 | 描述 |
| SecretId | 开发者拥有的项目身份识别 ID,用以身份认证,可在 API 密钥管理 页面获取 |
| SecretKey | 开发者拥有的项目身份密钥,可在 API 密钥管理 页面获取 |
javascript
import COS from 'cos-js-sdk-v5'
const cos = new COS({
SecretId: '',
SecretKey: ''
})
- 执行上传操作
javascript
cos.putObject({
Bucket: '', // 填入您自己的存储痛,必须字段
Region: '', // 存储桶所在地域,例如 ap-beijing,必须字段
Key: params.file.name, // 存储在桶里的对象键(例如 1.jpg a/b/test.txt),必须字段
StorageClass: 'STANDARD',
Body: , // 上传文件对象
onProgress: function (progressData) {
console.log(JSON.stringify(progressData)
}
}, function (err, data) {
// 上传成功返回的数据,data.location 为图片的地址
console.log(err || data)
})
- 图片上传成功,在存储桶中即可查看上传的图片。
阿里云 OSS
目前国内企业,使用最多的云服务为 阿里云 ,所以说咱们文章将会以 阿里云 为例进行开发。
阿里云的 OSS 服务,有新人三个月的免费试用。
我们将使用 OSS 进行图片的上传。
阿里云 OSS 使用流程:
-
注册登录 阿里云服务。
-
找到 阿里云 OSS 对象存储服务。
-
点击 立即开通。
-
选择 立即开通。
-
勾选服务,点击立即开通。
-
提示开通成功之后,可以进行两个操作。
-
点击 管理控制台 进入 OSS 控制台。
-
点击 对象存储新手入门 查看文档。
OSS 基础概念
OSS 中包含了很多的基础概念,可以点击 这里 直接进行访问。
需要大家了解的基础概念有:
-
Bucket:存储空间。
-
Object:存储文件。
创建存储桶(Bucket)
在 控制台 左侧点击 Bucket 列表 ,然后点击 创建 Bucket (读写权限为 私有)
使用 STS 临时访问凭证访问 OSS
在 Bucket 构建完成之后,接下来我们就需要去处理 访问凭证。
注意: 构建访问凭证,会涉及到服务端的配置。在实际开发中,不需要 前端工程师来处理访问凭证相关的内容。
具体的构建流程可以点击 这里 进行查看。
名词解释:
RAM(Resource Access Management)
STS(Security Token Service)
ARN是指云服务所定义的资源(Aliyun Resource Name)
上传图片到 Bucket 的流程分析
-
想要上传文件到 Bucket,我们需要使用 ali-sdk ali-oss。
-
利用 ali-oss 生成 OSS 对象。
-
在生成 OSS 对象时,需要传递 文件上传凭证:
-
accessKeyId。
-
accessKeySecret。
-
stsToken。
-
需要通过接口 /user/sts 获取 文件上传凭证。
整体的文件上传流程为:
-
通过接口 /user/sts 获取 文件上传凭证。
-
通过 npm i ali-oss 安装依赖包。
-
使用凭证中的数据构建 OSS 对象。点击这里查看文档。
配置 CORS 跨域处理
当我们尝试使用 put 方法上传文件时,会得到一个跨域的错误。
想要处理这个问题,则需要 配置跨域规则 。点击这里查看配置方案。
12. 使用临时凭证,上传裁剪图片到阿里云 OSS
本小节我们将讲解如何将裁剪后的图片上传到阿里云 OSS。
具体步骤如下:
-
安装 ali-oss 依赖。
-
通过接口获取临时访问凭证,生成 OSS 实例。
-
利用 ossClient.put 方法,完成对应上传。
接下来我们一步一步去做:
- 安装 ali-oss 依赖。
bash
npm i --save ali-oss@6.17.0
- 创建 src/utils/sts.js 模块,用来生成 OSS 实例。
javascript
import OSS from 'ali-oss'
import { REGION, BUCKET } from '@/constants'
import { getSts } from '@/api/sys'
export const getOSSClient = async () => {
const res = await getSts()
return new OSS({
// yourRegion填写Bucket所在地域。以华东1(杭州)为例,Region填写为oss-cn-hangzhou。
region: REGION,
// 从STS服务获取的临时访问密钥(AccessKey ID和AccessKey Secret)。
accessKeyId: res.Credentials.AccessKeyId,
accessKeySecret: res.Credentials.AccessKeySecret,
// 从STS服务获取的安全令牌(SecurityToken)。
stsToken: res.Credentials.SecurityToken,
// 填写Bucket名称。
bucket: BUCKET,
// 刷新 token,在 token 过期后自动调用(但是并不生效,可能会在后续的版本中修复)
refreshSTSToken: async () => {
// 向您搭建的STS服务获取临时访问凭证。
const res = await getSts()
return {
accessKeyId: res.Credentials.AccessKeyId,
accessKeySecret: res.Credentials.AccessKeySecret,
stsToken: res.Credentials.SecurityToken
}
},
// 刷新临时访问凭证的时间间隔,单位为毫秒。
refreshSTSTokenInterval: 5 * 1000
})
}
- 在 constants 中定义 REGION,BUCKET
javascript
// STS 上传数据
export const REGION = 'oss-cn-beijing'
export const BUCKET = 'imooc-front'
javascript
/**
* 进行 OSS 上传
*/
let ossClient = null
let store = useStore()
const putObjectToOSS = async (file) => {
if (!ossClient) {
ossClient = await getOSSClient()
}
try {
// 因为当前凭证只具备 images 文件夹下的访问权限,所以图片需要上传到 images/xxx.xx 。否则你将得到一个 《AccessDeniedError: You have no right to access this object because of bucket acl.》 的错误
const fileTypeArr = file.type.split('/')
const fileName = `${store.getters.userInfo.username}/${Date.now()}.${
fileTypeArr[fileTypeArr.length - 1]
}`
// 文件存放路径,文件
const res = await ossClient.put(`images/${fileName}`, file)
// 通知服务器
onChangeProfile(res.url)
} catch (e) {
message('error', e)
}
}
/**
* 上传新头像到服务器
*/
const onChangeProfile = async (avatar) => {
// 更新本地数据
store.commit('user/setUserInfo', {
...store.getters.userInfo,
avatar
})
// 更新服务器数据
await putProfile(store.getters.userInfo)
// 通知用户
message('success', '用户头像修改成功')
// 关闭 loading
loading.value = false
// 关闭 dialog
close()
}
/**
* 关闭事件
*/
const close = () => {
emits(EMITS_CLOSE)
}
13. 完成头像更新操作
为了代码连贯性,故代码写在上一小节中。
14. 登录鉴权解决方案
javascript
// src/permission.js
import router from '@/router'
import store from '@/store'
import { message } from '@/libs'
/**
* 处理需登录页面的访问权限
*/
router.beforeEach((to, from) => {
// 无需登录的页面访问
if (!to.meta.user) {
return
}
// 已登录,可进入
if (store.getters.token) {
return true
}
// 未登录,警告然后返回首页
message('warn', '登录失效,请重新登录!')
return '/'
})
15: 总结
在本篇文章中,我们接触到了两个新的通用组件:
-
input
-
dialog
除此之外,我们还接触到了头像裁剪、OSS、COS 的概念。这些概念可能对有些同学而言会比较新奇。大家也可以自己申请一个对应的 OSS 或者 COS 的账号(推荐 COS)。走一遍完整的流程,这样大家会对这个操作更加的熟悉。