Vue3 + Element-Plus + vue-draggable-plus 实现图片拖拽排序和图片上传到阿里云 OSS(最新保姆级)父组件实现真正上传
- 1、效果展示
- 2、UploadImage.vue 组件封装
- 3、相关请求封装
- 4、SwiperConfig.vue 调用组件
- 5、后端接口
1、效果展示
如果没有安装插件,请先安装 vue-draggable-plus 插件:
js
cnpm install vue-draggable-plus
2、UploadImage.vue 组件封装
js
<template>
<div class="draggable_image_upload">
<VueDraggable class="box-uploader" ref="draggableRef" v-model="curList" :animation="600" easing="ease-out"
ghostClass="ghost" draggable="ul" @start="onStart" @update="onUpdate">
<!-- 使用element-ui el-upload自带样式 -->
<ul v-for="(item, index) in curList" :key="index" class="el-upload-list el-upload-list--picture-card">
<li class="el-upload-list__item is-success animated"
:style="{ 'height': props.height + ' !important', 'width': props.width + ' !important', 'margin-right': props.space + ' !important' }">
<el-image class="originalImg" :src="item.url" :preview-src-list="[item.url]"
:style="{ 'height': props.height + ' !important', 'width': props.width + ' !important' }" />
<label class="el-upload-list__item-status-label">
<el-icon class="el-icon--upload-success el-icon--check">
<Check />
</el-icon>
</label>
<span class="el-upload-list__item-actions">
<!-- 预览 -->
<span class="el-upload-list__item-preview" @click="handleImgPreview('originalImg', index)">
<el-icon>
<ZoomIn />
</el-icon>
</span>
<!-- 删除 -->
<span class="el-upload-list__item-delete" @click="onImageRemove(item.url)">
<el-icon>
<Delete />
</el-icon>
</span>
</span>
</li>
</ul>
<!-- 上传组件 -->
<el-upload v-model:file-list="curList" :action="UPLOAD_IMG_URL" :multiple="multiple"
list-type="picture-card" :accept="accept" :show-file-list="false" :before-upload="beforeImgUpload"
:on-success="handleImgSuccess" ref="uploadRef" :auto-upload="autoUpload" drag>
<el-icon :style="{ 'height': props.height + ' !important', 'width': props.width + ' !important' }">
<Plus />
</el-icon>
</el-upload>
</VueDraggable>
</div>
</template>
<script name="DraggableImageUpload" setup>
import { UPLOAD_IMG_URL, uploadImageApi } from "@/api/upload/index"; //请求url
import { Check, Delete, Download, Plus, ZoomIn } from '@element-plus/icons-vue';
import { ElMessage } from "element-plus";
import { getCurrentInstance, onMounted, reactive, ref, toRefs, computed } from 'vue';
import { VueDraggable } from 'vue-draggable-plus';
const draggableRef = ref(null);
const { proxy } = getCurrentInstance();
const props = defineProps({
accept: {
type: String,
default: ".jpg,.jpeg,.png"
},
limit: {
type: Number,
default: 9999
},
multiple: {
type: Boolean,
default: true
},
autoUpload: {
type: Boolean,
default: false
},
height: {
type: String,
default: "100px"
},
width: {
type: String,
default: "100px"
},
space: {
type: String,
default: "20px"
},
modelValue: {
type: Array,
default: () => []
}
});
const {
modelValue
} = toRefs(props);
const data = reactive({
maxImgsLen: 0,
imgList: []
});
const { maxImgsLen, imgList } = toRefs(data);
const emit = defineEmits(["update:modelValue", "upload-failure", "upload-success"]);
const curList = computed({
get() {
return props.modelValue;
},
set(newValue) {
emit("update:modelValue", newValue);
}
});
const onStart = () => {
console.log('start');
};
const onUpdate = () => {
console.log(curList.value, 'update++++++++++++++');
};
const handleImgPreview = (domClass, index) => {
const dom = document.getElementsByClassName(domClass);
dom[index].firstElementChild.click(); //调用 el-image 的预览方法
};
const onImageRemove = (url) => {
const list = [...curList.value];
list.forEach((item, index) => {
if (url === item.url) {
list.splice(index, 1);
}
});
curList.value = list;
};
const beforeImgUpload = (rawFile) => {
const types = ["image/jpeg", "image/jpg", "image/png"];
const size = rawFile.size;
const isImage = types.includes(rawFile.type);
const isLt5M = size / 1024 / 1024 < 5;
if (!isImage) {
ElMessage("请上传jpeg、jpg、png类型的图片", {
type: "error"
});
return false;
} else if (!isLt5M) {
ElMessage("上传图片大小不能超过5MB", {
type: "error"
});
return false;
}
return true;
};
const uploadImage = async (file) => {
let res = await uploadImageApi(file);
return res.data;
}
/**
* 提供给外部的上传触发方法
*/
const triggerUpload = async () => {
for (let i = 0; i < modelValue.value.length; i++) {
const file = modelValue.value[i];
if (file.status === 'ready') {
modelValue.value[i] = {
...file,
status: 'uploading',
message: '上传中',
};
const formData = new FormData();
formData.append('file', file.raw); // 将 File 对象添加到 FormData
const uploadedUrl = await uploadImage(formData);
modelValue.value[i] = {
name: file.name,
size: file.size,
uid: file.uid,
status: 'success',
url: uploadedUrl,
};
}
emit('upload-success');
}
};
/**
* 提供给外部的函数
*/
defineExpose({
triggerUpload,
});
function handleImgSuccess() {
ElMessage.success("图片上传成功");
// This can be extended based on the response structure from the server
// For example, you might want to add the uploaded image to your imgList
const uploadedFile = {
url: response.url, // Assume the response contains the image URL
name: file.name,
size: file.size
};
curList.value.push(uploadedFile); // Add the uploaded image to curList
}
</script>
<style lang="scss" scoped>
::v-deep(.el-upload-dragger) {
padding: 0;
display: flex;
justify-content: center;
align-items: center;
}
.draggable_image_upload {
.box-uploader {
display: flex;
flex-wrap: wrap;
vertical-align: middle;
:deep(.el-upload) {
border-radius: 4px;
.circle-plus {
width: 24px;
height: 24px;
}
&.el-upload-list {
&.el-upload__item {
width: 100px;
height: 100px;
margin: 0 17px 17px 0;
border-color: #e7e7e7;
padding: 3px;
}
}
&.el-upload--picture-card {
border-style: dashed;
margin-right: 17px;
}
}
.el-upload-list__item {
margin: 0 17px 17px 0;
border-color: #e7e7e7;
padding: 3px;
.originalImg {
:deep(.el-image__preview) {
-o-object-fit: contain;
object-fit: contain;
}
}
}
ul:nth-child(6n+6) {
li {
margin-right: 0;
}
}
}
}
</style>
3、相关请求封装
http/index.ts
js
/*
* @Date: 2024-08-17 14:18:14
* @LastEditors: zhong
* @LastEditTime: 2024-09-17 19:22:36
* @FilePath: \rentHouseAdmin\src\api\upload\index.ts
*/
import axios from 'axios'
import type {
AxiosInstance,
AxiosError,
AxiosRequestConfig,
AxiosResponse,
} from 'axios'
import { ElMessage } from 'element-plus'
import { useUserStore } from '@/store/modules/user'
import { ResultEnum } from '@/enums/httpEnums'
import { ResultData } from './type'
import { LOGIN_URL } from '@/config/config'
import { RESEETSTORE } from '../reset'
import router from '@/router'
export const service: AxiosInstance = axios.create({
// 判断环境设置不同的baseURL
baseURL: import.meta.env.PROD ? import.meta.env.VITE_APP_BASE_URL : '/',
timeout: ResultEnum.TIMEOUT as number,
})
/**
* @description: 请求拦截器
* @returns {*}
*/
service.interceptors.request.use(
(config) => {
const userStore = useUserStore()
const token = userStore.token
if (token) {
config.headers['access-token'] = token
}
return config
},
(error: AxiosError) => {
ElMessage.error(error.message)
return Promise.reject(error)
},
)
/**
* @description: 响应拦截器
* @returns {*}
*/
service.interceptors.response.use(
(response: AxiosResponse) => {
const { data } = response
// * 登陆失效
if (ResultEnum.EXPIRE.includes(data.code)) {
RESEETSTORE()
ElMessage.error(data.message || ResultEnum.ERRMESSAGE)
router.replace(LOGIN_URL)
return Promise.reject(data)
}
if (data.code && data.code !== ResultEnum.SUCCESS) {
ElMessage.error(data.message || ResultEnum.ERRMESSAGE)
return Promise.reject(data)
}
return data
},
(error: AxiosError) => {
// 处理 HTTP 网络错误
let message = ''
// HTTP 状态码
const status = error.response?.status
switch (status) {
case 401:
message = 'token 失效,请重新登录'
break
case 403:
message = '拒绝访问'
break
case 404:
message = '请求地址错误'
break
case 500:
message = '服务器故障'
break
default:
message = '网络连接故障'
}
ElMessage.error(message)
return Promise.reject(error)
},
)
/**
* @description: 导出封装的请求方法
* @returns {*}
*/
const http = {
get<T>(
url: string,
params?: object,
config?: AxiosRequestConfig,
): Promise<ResultData<T>> {
return service.get(url, { params, ...config })
},
post<T>(
url: string,
data?: object,
config?: AxiosRequestConfig,
): Promise<ResultData<T>> {
return service.post(url, data, config)
},
put<T>(
url: string,
data?: object,
config?: AxiosRequestConfig,
): Promise<ResultData<T>> {
return service.put(url, data, config)
},
delete<T>(
url: string,
data?: object,
config?: AxiosRequestConfig,
): Promise<ResultData<T>> {
return service.delete(url, { data, ...config })
},
}
export default http
http/type.ts
js
// * 请求响应参数(不包含data)
export interface Result {
code: number
message: string
success?: boolean
}
// * 请求响应参数(包含data)
export interface ResultData<T = any> extends Result {
data: T
}
index.ts
js
/*
* @Date: 2024-08-17 14:18:14
* @LastEditors: zhong
* @LastEditTime: 2024-09-17 19:22:36
* @FilePath: \rentHouseAdmin\src\api\upload\index.ts
*/
// 上传基础路径
export const BASE_URL = import.meta.env.PROD
? import.meta.env.VITE_APP_BASE_URL
: ''
// 图片上传地址
export const UPLOAD_IMG_URL = `/admin/file/upload`
import http from '@/utils/http'
/**
* @description 上传图片接口
* @param params
*/
export function uploadImageApi(file: FormData) {
return http.post<any[]>(
UPLOAD_IMG_URL,
file
)
}
4、SwiperConfig.vue 调用组件
c
<!--
* @Date: 2024-12-14 15:08:55
* @LastEditors: zhong
* @LastEditTime: 2024-12-15 20:51:31
* @FilePath: \admin\src\views\small\smallSetting\components\SwiperConfig.vue
-->
<template>
<div class="card main">
<div class="left">
<div style="text-align: center;margin-bottom: 20px;">
<text style="font-size: 20px;">云尚社区</text>
</div>
<div class="mt-4">
<el-input style="border-radius: 10px;" size="small" placeholder="请输入关键字" :prefix-icon="Search" />
</div>
<div style="margin-top: 10px;">
<el-carousel :interval="4000" type="card" class="card" height="100px" indicator-position="none"
style="padding: 6px 0;border-radius: 10px;">
<el-carousel-item v-for="(item, index) in data" :key="index">
<el-image :src="item.settingValue" style="height: 100px;border-radius: 6px;" />
</el-carousel-item>
</el-carousel>
</div>
<div class="card menu">
<div v-for="(item, index) in menuList" :key="index" class="menu-item">
<div style="display: flex; flex-direction: column;">
<el-image :src="item.url" style="height: 60px;width: 70px;;border-radius: 6px;" />
<el-text style="margin-top: 8px;">{{ item.name }}</el-text>
</div>
</div>
</div>
</div>
<el-divider direction="vertical" style="height: 620px;margin-left: 50px;border-width: 2px;">可选配置</el-divider>
<div class="right">
<el-tag type="" size="large" effect="dark">轮播图配置</el-tag>
<div style="display: flex;margin-top: 20px;">
<div v-for="(item, index) in data" :key="index" style="padding-right: 20px;">
<el-image :src="item.settingValue" style="height: 80px;width: 160px;;border-radius: 6px;" />
</div>
</div>
<div style="margin-top: 20px;">
<DraggableImageUpload @upload-success="onUploadSuccess" v-model="uploadImageArray" ref="imageUploadRef"
height="80px" width="160px" space="20px" />
<el-button type="" @click="handleImageUpload" style="margin-top: 20px;">替换</el-button>
</div>
</div>
</div>
</template>
<script setup>
import DraggableImageUpload from '@/components/UploadImage/UploadImage.vue'
import { getSystemSettingsConfigApi } from '@/api/system';
import { onMounted, ref } from 'vue';
import { Search } from '@element-plus/icons-vue';
const data = ref([]);
const uploadImageArray = ref([]);
const getSystemSettingsConfig = async () => {
let res = await getSystemSettingsConfigApi();
data.value = res.data.settingMap.one;
console.log(data.value);
}
onMounted(() => {
getSystemSettingsConfig();
})
// 菜单信息
const menuList = ref([]);
const imageUploadRef = ref(null); // 创建 ref 引用
const handleImageUpload = () => {
imageUploadRef.value.triggerUpload();
}
// 上传成功回调
const onUploadSuccess = () => {
console.log(uploadImageArray.value);
};
</script>
<style lang="scss" scoped>
::v-deep(li.el-upload-list__item.is-success) {
margin-right: 20px;
height: 80px;
width: 160px;
}
::v-deep(.el-upload.el-upload--picture-card) {
height: 80px;
width: 160px;
}
.main {
display: flex;
justify-content: space-between;
padding: 20px;
}
.menu {
display: flex;
align-items: center;
justify-content: center;
flex-wrap: wrap;
/* 自动换行 */
gap: 20px;
row-gap: 15px;
/* 元素间距 */
max-height: calc(40px * 2 + 20px * 2 + 50px);
/* 限制两排的总高度 */
overflow: hidden;
/* 超出隐藏 */
margin-top: 10px;
border-radius: 10px;
}
.item {
width: calc(25% - 10px);
/* 每排最多显示 4 个 */
box-sizing: border-box;
}
.content {
display: flex;
flex-direction: column;
align-items: center;
}
::v-deep(.el-input__wrapper) {
height: 26px;
border-radius: 20px;
}
.left {
padding: 10px;
height: 600px;
background-color: #f8f8f8;
width: 400px;
}
.right {
margin-left: 50px;
flex: 1;
height: 600px;
}
</style>
5、后端接口
FileUploadController.java
java
package com.zhong.controller.apartment;
import com.zhong.result.Result;
import com.zhong.utils.AliOssUtil;
import io.minio.errors.*;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import java.util.UUID;
@Tag(name = "文件管理")
@RequestMapping("/admin/file")
@RestController
public class FileUploadController {
@Autowired
private AliOssUtil ossUtil;
@Operation(summary = "上传文件")
@PostMapping("/upload")
public Result<String> upload(@RequestParam MultipartFile file) throws ServerException, InsufficientDataException, ErrorResponseException, IOException, NoSuchAlgorithmException, InvalidKeyException, InvalidResponseException, XmlParserException, InternalException {
// 获取文件原名
String originalFilename = file.getOriginalFilename();
// 防止重复上传文件名重复
String fileName = null;
if (originalFilename != null) {
fileName = UUID.randomUUID() + originalFilename.substring(originalFilename.indexOf("."));
}
// 把文件储存到本地磁盘
// file.transferTo(new File("E:\\SpringBootBase\\ProjectOne\\big-event\\src\\main\\resources\\flies\\" + fileName));
String url = ossUtil.uploadFile(fileName, file.getInputStream());
System.out.println();
return Result.ok(url);
}
}
AliOssUtil.class
java
package com.zhong.utils;
import com.aliyun.oss.ClientException;
import com.aliyun.oss.OSS;
import com.aliyun.oss.OSSClientBuilder;
import com.aliyun.oss.OSSException;
import com.aliyun.oss.model.PutObjectRequest;
import com.aliyun.oss.model.PutObjectResult;
import com.zhong.result.Result;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
import org.springframework.stereotype.Service;
import java.io.IOException;
import java.io.InputStream;
import java.text.SimpleDateFormat;
import java.util.Date;
/**
* @ClassName : AliOssUtil
* @Description : 阿里云上传服务
* @Author : zhx
* @Date: 2024-03-1 20:29
*/
@Component
@Service
public class AliOssUtil {
@Value("${alioss.endpoint}")
private String ENDPOINT;
@Value("${alioss.bucketName}")
private String BUCKETNAME;
@Value("${alioss.access_key}")
private String ACCESS_KEY;
@Value("${alioss.access_key_secret}")
private String ACCESS_KEY_SECRET;
public String uploadFile(String objectName, InputStream inputStream) {
String url = "";
// 创建OSSClient实例。
System.out.println(ACCESS_KEY);
OSS ossClient = new OSSClientBuilder().build(ENDPOINT, ACCESS_KEY, ACCESS_KEY_SECRET);
try {
// 创建PutObjectRequest对象。
// 生成日期文件夹路径,例如:2022/04/18
SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy.MM.dd");
String dateStr = dateFormat.format(new Date());
PutObjectRequest putObjectRequest = new PutObjectRequest(BUCKETNAME, dateStr + "/" + objectName, inputStream);
// 如果需要上传时设置存储类型和访问权限,请参考以下示例代码。
// ObjectMetadata metadata = new ObjectMetadata();
// metadata.setHeader(OSSHeaders.OSS_STORAGE_CLASS, StorageClass.Standard.toString());
// metadata.setObjectAcl(CannedAccessControlList.Private);
// putObjectRequest.setMetadata(metadata);
// 上传文件。
PutObjectResult result = ossClient.putObject(putObjectRequest);
url = "https://" + BUCKETNAME + "." + ENDPOINT.substring(ENDPOINT.lastIndexOf("/") + 1) + "/" + dateStr + "/" + objectName;
} catch (OSSException oe) {
System.out.println("Caught an OSSException, which means your request made it to OSS, "
+ "but was rejected with an error response for some reason.");
System.out.println("Error Message:" + oe.getErrorMessage());
System.out.println("Error Code:" + oe.getErrorCode());
System.out.println("Request ID:" + oe.getRequestId());
System.out.println("Host ID:" + oe.getHostId());
} catch (ClientException ce) {
System.out.println("Caught an ClientException, which means the client encountered "
+ "a serious internal problem while trying to communicate with OSS, "
+ "such as not being able to access the network.");
System.out.println("Error Message:" + ce.getMessage());
} finally {
if (ossClient != null) {
ossClient.shutdown();
}
}
return url;
}
public Result deleteFile(String objectName) {
System.out.println(objectName);
// 创建OSSClient实例。
OSS ossClient = new OSSClientBuilder().build(ENDPOINT, ACCESS_KEY, ACCESS_KEY_SECRET);
try {
// 删除文件。
System.out.println(objectName);
System.out.println(objectName.replace(",", "/"));
ossClient.deleteObject(BUCKETNAME, objectName.replace(",", "/"));
return Result.ok("删除成功!");
} catch (OSSException oe) {
System.out.println("Caught an OSSException, which means your request made it to OSS, "
+ "but was rejected with an error response for some reason.");
System.out.println("Error Message:" + oe.getErrorMessage());
System.out.println("Error Code:" + oe.getErrorCode());
System.out.println("Request ID:" + oe.getRequestId());
System.out.println("Host ID:" + oe.getHostId());
} catch (ClientException ce) {
System.out.println("Caught an ClientException, which means the client encountered "
+ "a serious internal problem while trying to communicate with OSS, "
+ "such as not being able to access the network.");
System.out.println("Error Message:" + ce.getMessage());
} finally {
if (ossClient != null) {
ossClient.shutdown();
}
}
return Result.fail(555,"上传失败!");
}
}
application.yml
yml
alioss: # 阿里云配置
endpoint: "https://cn-chengdu.oss.aliyuncs.com" # Endpoint以西南(成都)为例,其它Region请按实际情况填写。
bucketName: "" # 填写Bucket名称,例如examplebucket。
access_key: "" # 点击头像->Accesskey管理查看 秘钥
access_key_secret: "" # 密码