效果演示
先说一下我们的需求,我们的需求就是文件上传,之前的接口是只支持上传图片的,之后需求是需要支持上传pdf,所以我就得换接口,把原先图片上传的接口换为后端ceph,但是其实大致的处理流程都差不多,都是上传到后端然后得到url地址。
要实现点击预览文件,那么就需要使用到element的groupPreview。
前端
文件上传的页面使用的是ElementUI的
下面是index.vue页面,有点小bug,不过在我坚持不懈的努力下(大概15h吧哈哈哈),终于给他解决了,只能说,感觉这是“精通前端”的第一步。
然后之后发现了一个问题就是如果用户在文件还没有上传完毕之前,就直接点击了提交,那么就会导致文件的丢失,所以之后我们添加了一个文件上传中的一个全局锁定,保证此时用户不能进行提交操作
页面代码如下
<!-- :on-preview="groupPreview" --> <template> <div> <el-upload class="upload-demo" :action="uploadAction" :on-remove="handleRemove" :on-success="handleSuccess" :on-error="handleError" :before-upload="beforeUpload" :on-preview="groupPreview" :file-list="fileListTemp" :on-progress="testFile" > <el-button size="small" type="primary">点击上传</el-button> <div slot="tip" class="el-upload__tip">只能上传jpg/png/pdf文件,且不超过20M<br> 上传完毕之后请审核一下您上传的文件在确认是否提交 </div> </el-upload> </div> </template> <script setup name="uploadImage"> import { ref, defineProps, onMounted, defineEmits } from 'vue'; import { ElMessage,ElLoading } from 'element-plus'; const props = defineProps(['modelValue']) const fileList = ref([]) let fileListTemp = ref([]) const urls = ref([]) const uploadAction = ref('/merchant/api/common/upload/image') const emits = defineEmits(['update:modelValue','input']) onMounted(() => { setDefaultFileList() }) const setDefaultFileList = () => { //这里的modelValue就是v-model传递过来的files console.log("----props.modelValue----"); console.log(props.modelValue); console.log("------fileListTemp--"); if (props.modelValue && props.modelValue.length > 0) { fileListTemp.value = [] props.modelValue.forEach(element => { let index = element.indexOf("|"); fileListTemp.value.push({ name: element.substr(0,index), url: element.substr(index+1), }) }); console.log(fileListTemp) console.log("--------"); } } const handleRemove = (file, fileList) => { fileListTemp.value = [] emits("update:modelValue", fileListTemp); } let loading = null const testFile = ()=>{ loading = ElLoading.service({ lock:true, text:'文件上传中...' }) } const handleSuccess = (response, file, fileList) => { let fileTemp = response.data.name+"|"+response.data.url; console.log("--------fileTemp---------") console.log(fileTemp); console.log("-----------------") fileListTemp.value.push({ fileTemp }); // fileListTemp.value.push(response.data.url); console.log("---------文件集合fileListTemp.value--------") console.log(fileListTemp.value); console.log("-----------------") emits("update:modelValue", fileListTemp); loading.close() } const handleError = (error) => { console.log('handleError', error) ElMessage.error('文件上传失败'); } const groupPreview = (file)=>{ window.open(file.url); } const beforeUpload = (file) => { // console.log(file) const isLt2M = file.size / 1024 / 1024 < 20; if (!isLt2M) { ElMessage.error('上传文件大小不能超过 20MB!'); } return isLt2M; } </script>
大概情况就是我们添加文件的时候,会发送一个请求到后端,这个后端的路径为
const uploadAction = ref('/merchant/api/common/upload/image')
当点击上传图片之后,我们的网页会发送这个请求,这个请求其实是会被router处理的,如下
var express = require("express"); var router = express.Router(); var request = require("superagent"); var multer = require("multer"); module.exports = (app) => { /** * 上传图片 */ router.post("/upload/image", async (req, res, next) => { var storage = multer.memoryStorage(); var upload = multer({ storage }).single("file"); upload(req, res, async (err) => { try { const url = "http://localhost:8080/supplier/outerapi/api/ceph/upload"; const response = await request.post(url).attach("file", req.file.buffer, req.file.originalname); const result = JSON.parse(response.text); // console.log(result) res.send({ code: 200, msg: "图片上传成功", data: { name: result.data[0].name, //结构为:name | url url: result.data[0].url, // url }, }); } catch (err) { res.send({ code: 500, msg: "图片上传异常", }); } }); }); //使用/merchant/api/common作为路径前缀 app.use("/merchant/api/common", router); };
可以看到这里有一个url,这个url就是你的后端处理前端文件上传的那个接口了。
并且可以看到我们的返回类型要求是code,msg,data,其中data要求有name和url,分别是文件名称和文件路径。
后端Java
我们先来看控制层代码,这里我们的OSS服务使用的是ceph,你也可以使用minio等来代替。
/** * 上传订单附件接口,文件可以一次传多个 * Content-Type:multipart/form-data; * 文件参数名:file * * @param files 请求的文件 * @return 返回 */ @PostMapping("/upload") public BaseResponse<List<FileInfo>> uploadFile(@RequestParam("file") List<MultipartFile> files) { BaseResponse<List<FileInfo>> res = new BaseResponse<List<FileInfo>>(); res.setCode(500); res.setMsg("上传失败,请稍后重试"); ArrayList<FileInfo> listFile = new ArrayList<FileInfo>(); try { //遍历请求头里的文件 //for (int i = 0; i < files.size(); i++) { for (MultipartFile file : files) { //获取文件的原始文件名 String fileName = file.getResource().getFilename(); //获取文件后缀,如 .jpg String fileSuffix = fileName.substring(fileName.lastIndexOf(".")); //ceph请求参数 CephRequest cephRequest = new CephRequest(); cephRequest.originFileName = fileName; //文件名设置一个随机数,避免重复 SimpleDateFormat dateFormat = new SimpleDateFormat("yyyyMMddHHmmss"); String uuid = UUID.randomUUID().toString(); cephRequest.setFileName(dateFormat.format(new Date()) + uuid.substring(uuid.lastIndexOf("-")) + fileSuffix); //这里可以设置块名称也可以不设置,不设置用默认的 cephRequest.setBlockName(""); cephRequest.setMaxDays(5 * 365); //五年 /* 设置请求头信息 */ cephRequest.setContextType(file.getContentType()); // 从OkHttp里面提交的postfileName 需要根据实际文件后缀修改contentType if (cephRequest.getContextType().equals("application/from-data")) { if (fileName.endsWith(".pdf")) { cephRequest.setContextType("application/pdf"); } else if (fileName.endsWith(".jpeg")) { cephRequest.setContextType("image/jpeg"); } else if (fileName.endsWith(".png")) { cephRequest.setContextType("image/png"); } else if (fileName.endsWith(".bmp")) { cephRequest.setContextType("image/bmp"); } else if (fileName.endsWith(".jpg")) { cephRequest.setContextType("image/jpg"); } } cephRequest.setFileStream(file.getInputStream()); LogUtil.info(JSON.toJSONString(cephRequest), "cephRequest"); //上传文件到ceph var cephResponse = cephService.uploadCeph(cephRequest); if (cephResponse.isOk()) { //ceph上传成功后,添加到返回参数里 listFile.add(new FileInfo(cephRequest.getOriginFileName(),cephResponse.getFileUrl())); } else { res.setMsg(cephResponse.getErrorMsg()); return res; } } //上传成功的标识 if (listFile.size() > 0) { res.setCode(200); res.setMsg("ok"); } } catch (Exception ex) { res.setCode(500); res.setMsg("上传失败,请稍后重试!" + ex.getMessage()); } finally { res.setData(listFile); } return res;
实体类
@Data public class CephRequest { /** * 要保存的文件名称 */ public String fileName; /** * 要上传的文件流 */ public InputStream fileStream; /** * 设置文件内容的类型 */ public String contextType; /** * 要保存的天数,不传默认是365*3 */ public int maxDays; /** * 块名称,不传用默认的 */ public String blockName; /** * 原始文件名. */ public String originFileName; }
然后是ceph的service层
@Service public class CephService { /** * CEPH服务地址 */ public static String SERVICE_URL = ""; /** * 块名称,可以自定义修改 */ public static String BLOCK_NAME = ""; /** * ceph key */ public static String ACCESS_KEY = ""; /** * ceph密钥 */ public static String SECRET_KEY = ""; /** * CEPH客户端 */ private AmazonS3Client s3client = null; /** * oss存储管理类 */ private final ICephManager cephManager; public CephService(ICephManager cephManager) { this.cephManager = cephManager; } public CephResponse uploadCeph(CephRequest param) { CephResponse res = new CephResponse(); res.setOk(false); try { // 一、初始化ceph客户端 AWSCredentials credentials = new BasicAWSCredentials(ACCESS_KEY, SECRET_KEY); ClientConfiguration clientCfg = new ClientConfiguration(); clientCfg.setProtocol(Protocol.HTTP); s3client = new AmazonS3Client(credentials, clientCfg); //设置存储的服务器 s3client.setEndpoint(SERVICE_URL); s3client.setS3ClientOptions(new S3ClientOptions().withPathStyleAccess(true)); //二、上传文件 InputStream input = param.getFileStream(); //参数验证 if ("".equals(param.getFileName())) { res.setErrorMsg("需要文件名参数"); return res; } else if (input == null) { res.setErrorMsg("未获取到文件流"); return res; } else if (param.getMaxDays() <= 0) { res.setErrorMsg("有效期必须大于0"); return res; } String bucket = BLOCK_NAME; if (!"".equals(param.getBlockName())) { bucket = param.getBlockName(); } // 1、先上传文件 ObjectMetadata meta = new ObjectMetadata(); meta.setContentLength(input.available()); // 这里如果有请求头就设置一下,没有就不设置 if (!StringUtils.isEmpty(param.getContextType())) { meta.setContentType(param.getContextType()); System.out.println(param.getContextType()); } //第二个参数可以修改为目录+文件名 s3client.putObject(bucket, param.getFileName(), input, meta); //2、生成文件的外链 GeneratePresignedUrlRequest request = new GeneratePresignedUrlRequest(bucket, param.getFileName()); Calendar nowTime = Calendar.getInstance(); nowTime.add(Calendar.MINUTE, 60 * 24 * param.getMaxDays()); request.setExpiration(nowTime.getTime()); URL url = s3client.generatePresignedUrl(request); //替换文件路径 换为最后项目需要使用的路径 //是否需要这个代码看你的业务 res.setFileUrl(url.toString().replace("http://.com/", "https:///")); if ("".equals(res.getFileUrl())) { res.setErrorMsg("ceph上传文件失败"); return res; } //3、保存到DB中. //TODO 如果保存到DB异常怎么办? try { SysCephfile file = new SysCephfile(); file.setCreateTime(LocalDateTime.now()); file.setBlockName(BLOCK_NAME); file.setExpireTime(LocalDateTime.now().plusDays(param.getMaxDays())); file.setOriginFileName(param.getOriginFileName()); file.setCephKey(param.getFileName()); // param.getContextType() file.setUrl(res.getFileUrl()); file.setFileId(0L); this.cephManager.saveCephFile(file); } catch (Exception ex) { //根据SysCephfile的信息去删除ceph中的图片 //s3client.deleteObject(); LogUtil.error(ex, "保存oss存储对象异常"); } res.setOk(true); res.setErrorMsg(""); } catch (Exception ex) { res.setErrorMsg("上传ceph异常," + ex.getMessage() + ex.getStackTrace()); } finally { return res; } } }
实体类
@Data @EqualsAndHashCode(callSuper = false) @TableName("sys_cephfile") public class SysCephfile implements Serializable { private static final long serialVersionUID = 1L; /** * 自增长主键 */ @TableId(value = "id", type = IdType.AUTO) @FieldName("自增长主键") private Long id; /** * 具体存储的blockname */ @FieldName("具体存储的blockname") private String blockName; /** * 创建时间 */ @FieldName("创建时间") private LocalDateTime createTime; /** * 过期时间 */ @FieldName("过期时间") private LocalDateTime expireTime; /** * 原始文件名 */ @FieldName("原始文件名") private String originFileName; /** * 传ceph的key(对外) */ @FieldName("传ceph的key(对外)") private String cephKey; /** * 上传后的url */ @FieldName("上传后的url") private String url; /** * 具体归属的档案ID,是0就是没保存的. */ @FieldName("具体归属的档案ID,是0就是没保存的.") private Long fileId; }
最后的mapper层使用的是mybatisplus,没有任何代码,纯CRUD,就不贴出了
数据存储格式
我们的这些文件数据是存储在MongoDB中的,格式为字符串,如下
“files”: [“QQ截图20230508204810.png|https://xxxxx”,“QQxxx.jpg|https://xxxxx”]
我们的数据结构就是一个字符串数组,并且我们把数组 | 前面的内容设定为文件名称,后面的为文件路径,之所以专门设计是由于业务需求导致,我也不想这么设计。
我的理解是前端会将先从数据库查询出来的数据存放到这个大数组中,之后我们直接使用里面对应的数据就好了。
其中,我们的uploadImage他本身是一个内嵌页面,是在script setup里面引入的,大概如下
<script setup name="certificateinfo"> const uploadImage = defineAsyncComponent(() => import('@/components/uploadImage/index.vue')); </script>
我们在需要使用这个上传页面的地方通过如下方式引入
<div class="width-full"> <el-form-item label="" :prop="'certificationList.'+index+'.files'" class="form-item-upload" :rules="[{ required: false, message: '请上传资质附件扫描件', trigger: 'change' }]"> <slot name="label"> <div class="flex-row"> <label class="el-form-item__label required">资质附件扫描件</label> <span class="el-form-item__label">资质附件扫描件 (原件或加盖公章复印件,该图片将会展示给用户,请确保图片清晰度)</span> <!-- <span class="example-btn">示例图</span> --> </div> </slot> <!-- <uploadImage v-model="item.pics"></uploadImage> --> <uploadImage v-model="item.files"></uploadImage> </el-form-item> </div>