目录
01: 通用组件:input 构建方案分析
02: 通用组件:input 构建方案
03: 构建用户资料基础样式
04: 用户基本资料修改方案
05: 处理不保存时的同步问题
06: 头像修改方案流程分析
07: 通用组件:Dialog 构建方案分析
08: 通用组件:Dialog 构建方案
09: 应用 Dialog 展示头像
10: 头像裁剪构建方案
11. 阿里云 OSS 与腾讯云 COS 对象存储方案分析
腾讯云 COS
COS SDK(COS 的包)
阿里云 OSS
OSS 基础概念
创建存储桶(Bucket)
使用 STS 临时访问凭证访问 OSS
上传图片到 Bucket 的流程分析
配置 CORS 跨域处理
12. 使用临时凭证,上传裁剪图片到阿里云 OSS
13. 完成头像更新操作
14. 登录鉴权解决方案
15: 总结
01: 通用组件:input 构建方案分析
期望通用组件 input 至少满足 4 个功能:
1. 支持单行文本输入
2. 支持多行文本输入
3. 通过 v-model 实现双向数据绑定
4. 支持最大文本输入
根据以上功能点,可判断出 input 组件要有 3 个 prop:
1. v-model
2. type:单行 or 多行
3. max:支持最大字符数
02: 通用组件:input 构建方案
- src/libs
- - input
- - - index.vue
// 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 端更方便。
- src/views
- - profile
- - - index.vue
// 路由信息
{
path: '/profile',
name: 'profile',
component: () => import('@/views/profile/index.vue'),
// 标记当前的页面只有用户登录之后才可以进入
meta: {
user: true
}
},
// 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: 用户基本资料修改方案
// 第一种方式:直接修改 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 端和 移动端 两种情况,我们需要分别进行处理:
1. PC 端:
1. 点击更换头像。
2. 选择对应文件。
3. 通过 Dialog 展示图片剪裁。
4. 剪裁后图片上传。
5. 功能完成。
2. 移动端:
1. 点击更换头像。
2. 选择对应文件。
3. 通过 popup 展示图片剪裁。
4. 剪裁后图片上传。
5. 功能完成。
由此可以发现,两者之间需要通过不同的组件进行裁剪展示。
因此我们后续的开发流程为:
1. 通用组件:Dialog。
2. 处理图片剪裁。
3. 处理剪裁后上传。
07: 通用组件:Dialog 构建方案分析
首先我们来处理 Dialog 通用组件。
对于 Dialog 通用组件而言,我们可以参考 confirm 组件的构建过程。
它们两个构建方案非常相似,唯二不同的地方是:
1. Dialog 无需通过方法调用的形式展示。
2. Dialog 的内容区可以渲染任意的内容。
排除这两点之后,其余与 confirm 完全相同。
08: 通用组件:Dialog 构建方案
- src/libs
- - dialog
- - - index.vue
<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 展示头像
// 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>
- src/views/profile
- - components
- - - change-avatar.vue
// 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 版本。
1. 安装 cropperjs
npm install cropperjs@1.5.12 --save
2. 在 src/views/profile/components/change-acatar.vue 中进行导入
import Cropper from 'cropperjs'
import 'cropperjs/dist/cropper.css'
3. 使用 new Cropper 进行初始化,区分 PC端 和 移动端:所有配置项
// 移动端配置对象
const mobileOptions = {
// 将裁剪框限制在画布的大小
viewMode: 1,
// 移动画布,裁剪框不动
dragMode: 'move',
// 裁剪框固定纵横比:1:1
aspectRatio: 1,
// 裁剪框不可移动
cropBoxMovable: false,
// 不可调整裁剪框大小
cropBoxResizable: false
}
// PC 端配置对象
const pcOptions = {
// 裁剪框固定纵横比:1:1
aspectRatio: 1
}
4.
/**
* 图片裁剪处理
*/
const imageTarget = ref(null)
let cropper = null
onMounted(() => {
/**
* 接收两个参数:
* 1. 需要裁剪的图片 DOM
* 2. options 配置对象
*/
cropper = new Cropper(
imageTarget.value,
isMobileTerminal.value ? mobileOptions : pcOptions
)
})
5.
/**
* 确定按钮点击事件
*/
const loading = ref(false)
const onConfirmClick = () => {
loading.value = true
// 获取裁剪后的图片
cropper.getCroppedCanvas().toBlob((blob) => {
// 裁剪后的 blob 地址
// console.log(URL.createObjectURL(blob))
putObjectToOSS(blob)
})
}
11. 阿里云 OSS 与腾讯云 COS 对象存储方案分析
当图片裁剪处理完成之后,接下来我们就可以处理裁剪之后的图片上传了。
通常情况下,在企业开发中,图片或文件的管理都会通过 对象存储 的方案进行。目前国内常见的对象存储云方案主要有两个平台:
1. 阿里云 OSS
2. 腾讯云 COS
腾讯云 COS
腾讯云 COS 目前提供了 实名认证赠送 6个月 对象存储的服务,点击跳转
这个服务对大家而言是非常好的一个练习服务,大家可以直接使用该服务来实现 裁剪图片上传到腾讯云。
以下为腾讯云 COS 使用流程:
1. 注册 腾讯云 账号,并完成 实名认证。
2. 选择免费产品。
3. 选择腾讯云 COS。
4. 点击立即开通。
5. 创建存储桶。
6. 点击创建。
7. 选择 公有读、私有写。
8. 一路下一步,创建即可。
此时你可以得到对应的存储桶,所有的文件都会被上传到该存储桶之中。
接下来就可以按照以下步骤进行图片上传:
COS SDK(COS 的包)
对应文档地址
1. 下载依赖包:
npm i cos-js-sdk-v5 --save
2. 构建 cos 实例:初始化 cos 对象参数
名称 | 描述 |
SecretId | 开发者拥有的项目身份识别 ID,用以身份认证,可在 API 密钥管理 页面获取 |
SecretKey | 开发者拥有的项目身份密钥,可在 API 密钥管理 页面获取 |
import COS from 'cos-js-sdk-v5'
const cos = new COS({
SecretId: '',
SecretKey: ''
})
3. 执行上传操作
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)
})
4. 图片上传成功,在存储桶中即可查看上传的图片。
阿里云 OSS
目前国内企业,使用最多的云服务为 阿里云,所以说咱们文章将会以 阿里云 为例进行开发。
阿里云的 OSS 服务,有新人三个月的免费试用。
我们将使用 OSS 进行图片的上传。
阿里云 OSS 使用流程:
1. 注册登录 阿里云服务。
2. 找到 阿里云 OSS 对象存储服务。
3. 点击 立即开通。
4. 选择 立即开通。
5. 勾选服务,点击立即开通。
6. 提示开通成功之后,可以进行两个操作。
1. 点击 管理控制台 进入 OSS 控制台。
2. 点击 对象存储新手入门 查看文档。
OSS 基础概念
OSS 中包含了很多的基础概念,可以点击 这里 直接进行访问。
需要大家了解的基础概念有:
1. Bucket:存储空间。
2. Object:存储文件。
创建存储桶(Bucket)
在 控制台 左侧点击 Bucket 列表,然后点击 创建 Bucket(读写权限为 私有)
使用 STS 临时访问凭证访问 OSS
在 Bucket 构建完成之后,接下来我们就需要去处理 访问凭证。
注意:构建访问凭证,会涉及到服务端的配置。在实际开发中,不需要 前端工程师来处理访问凭证相关的内容。
具体的构建流程可以点击 这里 进行查看。
名词解释:
RAM(Resource Access Management)
STS(Security Token Service)
ARN是指云服务所定义的资源(Aliyun Resource Name)
上传图片到 Bucket 的流程分析
1. 想要上传文件到 Bucket,我们需要使用 ali-sdk ali-oss。
2. 利用 ali-oss 生成 OSS 对象。
3. 在生成 OSS 对象时,需要传递 文件上传凭证:
1. accessKeyId。
2. accessKeySecret。
3. stsToken。
4. 需要通过接口 /user/sts 获取 文件上传凭证。
整体的文件上传流程为:
1. 通过接口 /user/sts 获取 文件上传凭证。
2. 通过 npm i ali-oss 安装依赖包。
3. 使用凭证中的数据构建 OSS 对象。点击这里查看文档。
配置 CORS 跨域处理
当我们尝试使用 put 方法上传文件时,会得到一个跨域的错误。
想要处理这个问题,则需要 配置跨域规则。点击这里查看配置方案。
12. 使用临时凭证,上传裁剪图片到阿里云 OSS
本小节我们将讲解如何将裁剪后的图片上传到阿里云 OSS。
具体步骤如下:
1. 安装 ali-oss 依赖。
2. 通过接口获取临时访问凭证,生成 OSS 实例。
3. 利用 ossClient.put 方法,完成对应上传。
接下来我们一步一步去做:
1. 安装 ali-oss 依赖。
npm i --save ali-oss@6.17.0
2. 创建 src/utils/sts.js 模块,用来生成 OSS 实例。
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
})
}
3. 在 constants 中定义 REGION,BUCKET
// STS 上传数据
export const REGION = 'oss-cn-beijing'
export const BUCKET = 'imooc-front'
4.
/**
* 进行 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. 登录鉴权解决方案
// 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: 总结
在本篇文章中,我们接触到了两个新的通用组件:
1. input
2. dialog
除此之外,我们还接触到了头像裁剪、OSS、COS 的概念。这些概念可能对有些同学而言会比较新奇。大家也可以自己申请一个对应的 OSS 或者 COS 的账号(推荐 COS)。走一遍完整的流程,这样大家会对这个操作更加的熟悉。