1、实现功能
- 图片、视频上传
- 相机、相册选择
- 上传loading
- 上传中断
- 图片、视频预览
- 文件删除
- 错误提示
- 文件体积限制
2、待增加
- 切片上传
3、代码
前提
没怎么开发过小程序,在写一个基于uniapp开发一个很基础的表单业务的时候,产品说要图片和视频都要可以预览,可以拍照、拍摄、相册等方式,所以写的时候跌跌撞撞,开发了这么一个可复用的组件,权当自用。有什么问题,大家可以一起完成。
模板
<view class="upload-file">
<!-- 标题区域 -->
<view class="upload-title">
<text>附件上传({{ maxSize ? `单个视频/图片不超过${maxSizeShowText}MB` : '支持视频/图片' }})</text>
</view>
<!-- 上传区域 -->
<view class="upload-area">
<view class="upload-item" v-for="(item, index) in preViewFileList" :key="index" :class="item.status">
<!-- 上传加载状态 -->
<image v-if="item.status == 'loading'" class="uploader-img" :class="item.status" src="~@/static/images/loading.svg" mode="scaleToFill" @tap="exampleAction(item)"></image>
<!-- 上传失败状态 -->
<!-- <image v-else-if="item.status == 'fail'" class="uploader-img" :class="item.status" src="~@/static/images/fail.svg" mode="scaleToFill" @tap="exampleAction(item)"></image> -->
<!-- 图片 -->
<image v-else-if="item.type == 'image'" class="uploader-img" :src="item.localSrc" @tap="exampleAction(item)" mode="scaleToFill"></image>
<!-- 视频 -->
<video v-else-if="item.type == 'video'" class="uploader-video" :src="item.localSrc" @tap="exampleAction(item)" object-fit="fill" :show-center-play-btn="false" :controls="false"></video>
</view>
<view class="upload-item" @tap="chooseVideoImage" v-if="uploadButtonShow()">
<view class="upload-button">
<image src="~@/static/images/uploadButton.svg" mode="scaleToFill" />
</view>
</view>
</view>
<!-- 预览区域 -->
<view class="upload-preview" v-if="previewShow">
<!-- 关闭按钮 -->
<view class="closeBtn" @tap="closePreview">×</view>
<!-- 图片预览 -->
<image class="uploade-image" v-if="previewFileData.type == 'image'" :src="previewFileData.localSrc" mode="aspectFit"></image>
<!-- 视频预览 -->
<video class="uploade-video" v-if="previewFileData.type == 'video'" :src="previewFileData.localSrc" object-fit="contain"></video>
</view>
<!-- 结果示例点击操作菜单 -->
<u-action-sheet class="actionSheet" :list="actionSheetList" @click="actionSheetClick" v-model="actionSheetShow" border-radius="24" :safe-area-inset-bottom="true"></u-action-sheet>
</view>
样式
@keyframes loading {
from {
-webkit-transform: rotate(0deg);
}
to {
-webkit-transform: rotate(360deg);
}
}
.upload-file {
width: 750rpx;
// border: 1px solid red;
padding: 32rpx 32rpx 31rpx 32rpx;
// 标题
.upload-title {
font-size: 28rpx;
color: rgba(14, 19, 25, 0.90);
line-height: 40rpx;
font-weight: 500;
}
// 上传区域
.upload-area {
margin-top: 33rpx;
display: flex;
flex-wrap: wrap;
.upload-item {
width: 72rpx;
height: 72rpx;
margin: 0 18rpx 18rpx 0;
border-radius: 8rpx;
overflow: hidden;
display: flex;
justify-content: center;
align-items: center;
&.loading,
&.fail {
background-color: rgba(0, 0, 0, .3);
}
image,
video {
width: 100%;
height: 100%;
&.loading {
width: 60%;
height: 60%;
animation: loading 1.5s linear infinite;
}
&.fail {
width: 60%;
height: 60%;
}
}
.upload-button {
width: 100%;
height: 100%;
}
}
}
.upload-preview {
padding: 20rpx;
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background-color: #000;
color: #fff;
display: flex;
justify-content: center;
align-items: center;
// 这个值是不是很大?我不想做动态计算了o(╥﹏╥)o,后面的小伙伴你们有更合适的方法来处理下吧
z-index: 99999999999999999;
.closeBtn {
position: absolute;
right: 30rpx;
top: 30rpx;
z-index: 999;
width: 50rpx;
height: 50rpx;
line-height: 42rpx;
text-align: center;
border: 4rpx solid #fff;
border-radius: 50%;
font-size: 42rpx;
}
.uploade-image {
width: 100%;
height: 100%;
}
.uploade-video {
width: 100%;
height: 100%;
}
}
}
逻辑
export default {
components: {
// ActionSheet
},
props: {
// 上传结果
value: {
type: Array,
default: [],
},
// 上传地址
actionUrl: {
type: String,
required: true,
},
// 上传请求方式
actionMethod: {
type: String,
default: "POST",
},
// 最大上传图片、视频数量,默认值为9
maxNumber: {
type: [String, Number],
},
// 文件大小限制
maxSize: {
type: Number,
}
},
watch: {
// 监听上传文件列表数据改变
fileList: {
immediate: true,
handler(newVal) {
this.$emit("input", newVal.filter(item => item.status == 'success').map(({ name, file }) => ({ name, file })))
}
}
},
computed: {
// 剩余可上传的图片、视频数量
remainder() {
if (Number(this.maxNumber)) {
return (Number(this.maxNumber) ? Number(this.maxNumber) : 9) - this.fileList.length
} else {
return 9
}
},
// 预览文件列表
preViewFileList() {
return this.fileList.filter(item => item.status != 'fail')
},
// 文件大小限制提示数字
maxSizeShowText() {
return this.maxSize / 1024 / 1024 > 1 ? (parseInt(this.maxSize / 1024 / 1024)) : (this.maxSize / 1024 / 1024).toFixed(2);
}
},
data() {
return {
// 文件列表
fileList: [],
// 控制是否显示
previewShow: false,
// 当前预览对象
previewFileData: {},
// 操作菜单显示状态
actionSheetShow: false,
// 操作菜单配置
successActionSheetList: [
{
disabled: true,
text: '附件操作',
color: 'blue',
fontSize: 28,
color: '#999999',
fontWeight: 400,
lineHeight: 108,
},
{
text: '预览',
fontFamily: " PingFangSC-Medium",
fontSize: 32,
color: 'rgba(14,19,25,0.90)',
fontWeight: 500,
},
{
text: '删除',
fontFamily: " PingFangSC-Medium",
fontSize: 32,
color: '#FF5C5C',
fontWeight: 500,
}
],
failActionSheetList: [
{
disabled: true,
text: '附件操作',
color: 'blue',
fontSize: 28,
color: '#999999',
fontWeight: 400,
lineHeight: 108,
},
{
text: '删除',
fontFamily: " PingFangSC-Medium",
fontSize: 32,
color: '#FF5C5C',
fontWeight: 500,
}
],
actionSheetList: [],
}
},
methods: {
// 选择图片、视频交互按钮
chooseVideoImage() {
uni.showActionSheet({
title: "选择上传类型",
itemList: ['图片', '视频'],
success: (res) => {
console.log(res)
if (res.tapIndex == 0) {
this.chooseImages()
} else {
this.chooseVideo()
}
}
})
},
// 上传图片
chooseImages() {
uni.chooseImage({
// 选择文件个数,默认9
count: this.remainder,
// 相册、相机
sourceType: ['album', 'camera'],
success: async (res) => {
console.log("选择图片成功:", res);
// 如果限制了文件大小,则只上传满足大小限制的图片
let filePaths = res.tempFiles.filter(({ size }) => this.maxSize ? size <= this.maxSize : true).map(({ path }) => path);
if (res.tempFilePaths.length - filePaths.length > 0) {
uni.showToast({
icon: "none",
title: `${res.tempFilePaths.length - filePaths.length}张图片超过${this.maxSizeShowText}MB,无法上传!`
})
}
// 此次上传任务列表
let pArr = [];
filePaths.forEach((filePath, index) => {
pArr.push(new Promise((resolve, reject) => {
let newFileIndex = this.fileList.length
let newFileData = {
status: 'loading',
type: 'image',
id: Math.random(),
uploadTask: {},
}
console.log('准备上传图片:URL', this.actionUrl);
newFileData.uploadTask = uni.uploadFile({
url: this.actionUrl,
method: this.actionMethod,
filePath: filePath,
name: 'file',
header: {
'apikey': this.$store.state.userInfo.apikey,
'session': this.$store.state.userInfo.session,
},
success: (res) => {
let data = JSON.parse(res.data);
if (data.code == 200) {
console.log('上传图片中介', data);
let fileInfo = data.data
if (fileInfo.ret == '200') {
// 上传成功
console.log('上传图片成功', fileInfo);
this.$set(this.fileList, newFileIndex, {
localSrc: filePath,
file: fileInfo.data.url,
name: fileInfo.data.file,
status: 'success',
type: 'image',
id: Math.random(),
})
// 上传成功:返回正确状态
resolve({ [index]: 'success' });
} else {
// 上传失败
console.log('上传图片失败', fileInfo);
this.$set(this.fileList, newFileIndex, {
status: 'fail',
type: 'image',
})
// 上传失败:返回失败状态
resolve({ [index]: 'fail' });
}
} else {
// 上传失败
console.log('上传图片失败', data);
this.$set(this.fileList, newFileIndex, {
status: 'fail',
type: 'image',
})
// 上传失败:返回失败状态
resolve({ [index]: 'fail' });
}
},
fail: (res) => {
if (res.errMsg.includes('abort')) {
// 取消上传
console.log('取消图片视频', res);
let targetIndex = this.fileList.findIndex(item => item.id == this.previewFileData.id)
this.fileList.splice(targetIndex, 1)
// 取消上传:返回取消状态
resolve({ [index]: 'abort' });
} else {
// 上传失败
console.log('上传图片失败', res);
this.$set(this.fileList, newFileIndex, {
status: 'fail',
type: 'image',
})
// 上传失败:返回失败状态
resolve({ [index]: 'fail' });
}
},
})
this.fileList.push(newFileData)
}))
})
let r = await Promise.all(pArr);
if (Array.isArray(r)) {
let successCount = r.filter(item => {
for (const key in item) {
return item[key] == 'fail'
}
})
if (successCount.length > 0) {
uni.showToast({
icon: "none",
title: `${successCount.length}张图片上传失败!`
})
}
}
},
fail: (res) => {
this.flag = '失败'
this.res = res;
}
});
},
// 上传视频
chooseVideo() {
uni.chooseVideo({
// 相册、相机
sourceType: ['album', 'camera'],
compressed: false,
success: (res) => {
console.log("选择视频成功:", res);
if (this.maxSize && res.size > this.maxSize) {
uni.showToast({
icon: "none",
title: `视频大小不能超过${this.maxSizeShowText}MB!`
})
return;
}
let filePath = res.tempFilePath;
let newFileIndex = this.fileList.length
let newFileData = {
status: 'loading',
type: 'video',
id: Math.random(),
uploadTask: {},
}
console.log('准备上传视频:URL', this.actionUrl);
newFileData.uploadTask = uni.uploadFile({
url: this.actionUrl,
method: this.actionMethod,
filePath: filePath,
name: 'file',
header: {
'apikey': this.$store.state.userInfo.apikey,
'session': this.$store.state.userInfo.session,
},
success: (res) => {
let data = JSON.parse(res.data);
if (data.code == 200) {
console.log('上传视频中介', data);
let fileInfo = data.data
if (fileInfo.ret == '200') {
// 上传成功
console.log('上传视频成功', fileInfo);
this.$set(this.fileList, newFileIndex, {
localSrc: filePath,
file: fileInfo.data.url,
name: fileInfo.data.file,
status: 'success',
type: 'video',
id: Math.random(),
})
return
} else {
// 上传失败
console.log('上传视频失败', fileInfo);
this.$set(this.fileList, newFileIndex, {
status: 'fail',
type: 'video',
})
}
} else {
// 上传失败
console.log('上传视频失败', data);
this.$set(this.fileList, newFileIndex, {
status: 'fail',
type: 'video',
})
}
// 提示上传失败
uni.showToast({
icon: "none",
title: `视频上传失败!`
})
},
fail: (res) => {
if (res.errMsg.includes('abort')) {
// 取消上传
console.log('取消上传视频', res);
let targetIndex = this.fileList.findIndex(item => item.id == this.previewFileData.id)
this.fileList.splice(targetIndex, 1)
} else {
// 上传失败
console.log('上传视频失败', res);
this.$set(this.fileList, newFileIndex, {
status: 'fail',
type: 'video',
})
// 提示上传失败
uni.showToast({
icon: "none",
title: `视频上传失败!`
})
}
},
})
this.fileList.push(newFileData)
},
fail: (res) => {
this.flag = '失败'
this.res = res;
}
})
},
// 点击结果示例
exampleAction(file) {
// 根据文件状态显示,显示不同的操作菜单
this.actionSheetList = file.status == 'success' ? this.successActionSheetList : this.failActionSheetList
// 显示操作菜单
this.actionSheetShow = true
// 当前操作的文件
this.previewFileData = file
},
// 预览文件
previewFile() {
console.log("触发了预览");
this.previewShow = true;
},
// 关闭预览
closePreview() {
this.previewShow = false;
},
// 控制是否显示上传按钮
uploadButtonShow() {
if (Number(this.maxNumber)) {
return this.fileList.length < Number(this.maxNumber)
}
return true;
},
// 点击操作菜单按钮
actionSheetClick(index) {
console.log(`点击了操作菜单`, index, this.actionSheetList[index].text)
let text = this.actionSheetList[index].text;
if (text == '预览') {
this.previewFile()
} else if (text == '删除') {
if (this.previewFileData.status == 'loading') {
// 如果是正在上传中,则取消上传
this.previewFileData.uploadTask?.abort && this.previewFileData.uploadTask.abort()
}
// 如果不是正在上传中(失败、成功)则删除该数据
let targetIndex = this.fileList.findIndex(item => item.id == this.previewFileData.id)
this.fileList.splice(targetIndex, 1)
}
},
}
}
组件内文件列表数据格式
[
{
localSrc: 'https://v1.uviewui.com/index/banner_1920x1080.png',
name: 'file1',
file: 'https://v1.uviewui.com/index/banner_1920x1080.png',
type: 'image',
status: "success",
id: Math.random(),
},
{
localSrc: 'https://v1.uviewui.com/index/banner_1920x1080.png',
type: 'image',
name: 'file2',
file: 'https://v1.uviewui.com/index/banner_1920x1080.png',
status: "loading",
id: Math.random(),
},
]
输出格式
[
{
name: 'file1',
file: 'https://v1.uviewui.com/index/banner_1920x1080.png',
},
{
name: 'file2',
file: 'https://v1.uviewui.com/index/banner_1920x1080.png',
},
]
4、问题解析
1)错误提示
当选择多张图片时,会出现部分图片上传失败的情况,需要统计失败的数量、并通知用户,所以这里采用了Promise的all来处理
// 此次上传任务列表
let pArr = [];
filePaths.forEach((filePath, index) => {
pArr.push(new Promise((resolve, reject) => {
uni.uploadFile({
url: this.actionUrl,
method: this.actionMethod,
filePath: filePath,
name: 'file',
success: (res) => {
// 上传成功:返回正确状态
resolve({ [index]: 'success' });
},
fail: (res) => {
// 上传失败:返回失败状态
resolve({ [index]: 'fail' });
},
})
this.fileList.push(newFileData)
}))
})
let r = await Promise.all(pArr);
if (Array.isArray(r)) {
let successCount = r.filter(item => {
for (const key in item) {
return item[key] == 'fail'
}
})
if (successCount.length > 0) {
uni.showToast({
icon: "none",
title: `${successCount.length}张图片上传失败!`
})
}
}
2)交互差异
正在上传的文件只能删除不能预览、上传成功的文件可以删除和预览,所以需要有字段表示上传的状态
在不同的情况下更新状态值
在模板中根据状态值不同做调整
另:保存了上传失败的结果,但是这里没有在模板中渲染出来,大家可以根据自己的需求看看要不要呈现上传失败的效果