Explorar o código

Update 更新文件上传组件,即文件预览组件

Yue hai 6 meses
pai
achega
cea7d8f1ed

+ 13 - 0
SERVER/VberAdminPlusV3/vber-modules/vber-system/src/main/java/com/vber/system/controller/system/SysOssController.java

@@ -96,6 +96,7 @@ public class SysOssController extends BaseController {
         SysOssUploadVo uploadVo = new SysOssUploadVo();
         uploadVo.setUrl(oss.getUrl());
         uploadVo.setFileName(oss.getOriginalName());
+        uploadVo.setFileId(oss.getObjectId());
         uploadVo.setOssId(oss.getOssId().toString());
         return R.ok(uploadVo);
     }
@@ -125,6 +126,18 @@ public class SysOssController extends BaseController {
         R.ok();
     }
 
+    /**
+     * 下载OSS对象
+     *
+     * @param objectId OSS对象ObjectId
+     */
+    @SaCheckPermission("system:oss:download")
+    @GetMapping("/download/o/{objectId}")
+    public void downloadByObjectId(@PathVariable String objectId, HttpServletResponse response) throws IOException {
+        ossService.downloadByObjectId(objectId, response);
+        R.ok();
+    }
+
     /**
      * 删除OSS对象存储
      *

+ 1 - 0
SERVER/VberAdminPlusV3/vber-modules/vber-system/src/main/java/com/vber/system/domain/SysOssExt.java

@@ -72,4 +72,5 @@ public class SysOssExt implements Serializable {
      */
     private String md5;
 
+    private String objectId;
 }

+ 5 - 0
SERVER/VberAdminPlusV3/vber-modules/vber-system/src/main/java/com/vber/system/domain/vo/SysOssUploadVo.java

@@ -20,6 +20,11 @@ public class SysOssUploadVo {
      */
     private String fileName;
 
+    /**
+     * 文件ID
+     */
+    private String fileId;
+
     /**
      * 对象存储主键
      */

+ 8 - 0
SERVER/VberAdminPlusV3/vber-modules/vber-system/src/main/java/com/vber/system/service/ISysOssService.java

@@ -94,6 +94,14 @@ public interface ISysOssService {
      */
     void download(Long ossId, HttpServletResponse response) throws IOException;
 
+    /**
+     * 文件下载方法,支持一次性下载完整文件
+     *
+     * @param objectId OSS对象ID
+     * @param response HttpServletResponse对象,用于设置响应头和向客户端发送文件内容
+     */
+    void downloadByObjectId(String objectId, HttpServletResponse response) throws IOException;
+
     /**
      * 删除OSS对象存储
      *

+ 20 - 1
SERVER/VberAdminPlusV3/vber-modules/vber-system/src/main/java/com/vber/system/service/impl/SysOssServiceImpl.java

@@ -190,7 +190,7 @@ public class SysOssServiceImpl implements ISysOssService, OssService {
     public SysOssVo getByObjectId(String objectId) {
         TenantHelper.enableIgnore();
         try {
-            return baseMapper.selectVoOne(Wrappers.lambdaQuery(SysOss.class).eq(SysOss::getObjectId, objectId));
+            return baseMapper.selectVoList(Wrappers.lambdaQuery(SysOss.class).eq(SysOss::getObjectId, objectId)).get(0);
         } finally {
             TenantHelper.disableIgnore();
         }
@@ -205,6 +205,23 @@ public class SysOssServiceImpl implements ISysOssService, OssService {
     @Override
     public void download(Long ossId, HttpServletResponse response) throws IOException {
         SysOssVo sysOss = SpringUtils.getAopProxy(this).getById(ossId);
+        downloadOss(response, sysOss);
+
+    }
+
+    /**
+     * 文件下载方法,支持一次性下载完整文件
+     *
+     * @param objectId OSS对象objectId
+     * @param response HttpServletResponse对象,用于设置响应头和向客户端发送文件内容
+     */
+    @Override
+    public void downloadByObjectId(String objectId, HttpServletResponse response) throws IOException {
+        SysOssVo sysOss = SpringUtils.getAopProxy(this).getByObjectId(objectId);
+        downloadOss(response, sysOss);
+    }
+
+    private void downloadOss(HttpServletResponse response, SysOssVo sysOss) throws IOException {
         if (ObjectUtil.isNull(sysOss)) {
             throw new ServiceException("文件数据不存在!");
         }
@@ -249,6 +266,7 @@ public class SysOssServiceImpl implements ISysOssService, OssService {
         SysOssExt ext1 = new SysOssExt();
         ext1.setFileSize(file.getSize());
         ext1.setContentType(file.getContentType());
+        ext1.setObjectId(uploadResult.getObjectId());
         // 保存文件信息
         return buildResultEntity(originalFileName, suffix, storage.getConfigKey(), uploadResult, ext1);
     }
@@ -279,6 +297,7 @@ public class SysOssServiceImpl implements ISysOssService, OssService {
         SysOssExt ext1 = new SysOssExt();
         ext1.setFileSize(file.getSize());
         ext1.setContentType(file.getContentType());
+        ext1.setObjectId(uploadResult.getObjectId());
         // 保存文件信息
         return buildResultEntity(originalFileName, suffix, storage.getConfigKey(), uploadResult, ext1);
     }

+ 16 - 5
UI/VAP_V3.VUE/src/components/table/VbDataTable.vue

@@ -552,10 +552,11 @@ const isContentSlots = (name: string | number): boolean => {
 		}) != null
 	)
 }
+
 const loadHandleBtns = () => {
 	toolbarHandleBtns.value = []
+	const _handleFuns = Object.assign({}, defaultHandleFuns, props.handleFuns ?? {})
 	if (!props.useCustomBtns && props.handlePerm) {
-		const _handleFuns = Object.assign({}, defaultHandleFuns, props.handleFuns ?? {})
 		apis.system.menuApi.menuChildrenByPerms(props.handlePerm).then((res) => {
 			if (res.data && res.data.length > 0) {
 				res.data.forEach((v: any) => {
@@ -593,16 +594,19 @@ const loadHandleBtns = () => {
 			}
 
 			toolbarHandleBtns.value.push(
-				...(props.customBtns ? _formatterCustomBtns(props.customBtns) : [])
+				...(props.customBtns ? _formatterCustomBtns(props.customBtns, _handleFuns) : [])
 			)
+			// console.log("toolbarHandleBtns", toolbarHandleBtns.value)
 		})
 	} else {
-		toolbarHandleBtns.value = props.customBtns ? _formatterCustomBtns(props.customBtns) : []
+		toolbarHandleBtns.value = props.customBtns
+			? _formatterCustomBtns(props.customBtns, _handleFuns)
+			: []
 	}
 }
-const _formatterCustomBtns = (btns: ToolBtn[]) => {
+const _formatterCustomBtns = (btns: ToolBtn[], handleFuns) => {
 	return btns.map((v) => {
-		return {
+		const btn: ToolBtn = {
 			...{
 				key: "",
 				show: true,
@@ -615,6 +619,9 @@ const _formatterCustomBtns = (btns: ToolBtn[]) => {
 			},
 			...v
 		} as ToolBtn
+
+		btn.clickFun = btn.clickFun || handleFuns[btn.key]
+		return btn
 	})
 }
 const customSearch = () => {
@@ -665,6 +672,9 @@ function search(isReset = true) {
 function getQueryParams() {
 	return innerQueryParams.value
 }
+function setQueryParams(params: any) {
+	innerQueryParams.value = params
+}
 function getSelected() {
 	return getSelecteds()[0]
 }
@@ -1049,6 +1059,7 @@ defineExpose({
 	getData,
 	getFirstRowData,
 	getQueryParams,
+	setQueryParams,
 	setSelectedId,
 	setSelected,
 	setSelecteds,

+ 3 - 1
UI/VAP_V3.VUE/src/components/upload/VbImagePreview.vue

@@ -15,6 +15,8 @@ const props = withDefaults(
 	{
 		width: 75,
 		height: 50,
+		prefixSrc: "/resource/oss/preview/",
+		url: "/resource/oss/preview/",
 		fit: "cover",
 		imageStyle: () => {
 			return {
@@ -52,7 +54,7 @@ function load() {
 	src_list.forEach((v) => {
 		if (props.url) {
 			Rs.get({
-				url: props.url + v,
+				url: props.url + v.split("$")[0],
 				loading: false,
 				responseType: "blob"
 			}).then((res: any) => {

+ 285 - 0
UI/VAP_V3.VUE/src/components/upload/VbOfficePreview.vue

@@ -0,0 +1,285 @@
+<script setup lang="ts">
+import Rs from "@@/services/RequestService"
+import { isExternal } from "@@/utils/validate"
+
+const props = withDefaults(
+	defineProps<{
+		src?: string
+		previewUrl?: string
+		fileId?: string
+		fileName?: string
+		fileExt?: string
+		prefixSrc?: string
+	}>(),
+	{
+		previewUrl: "resource/oss/preview",
+		downloadUrl: "resource/oss/download",
+		fileExt: "pdf",
+		prefixSrc: ""
+	}
+)
+const emits = defineEmits<{ (e: "update:modelValue", v: string): void }>()
+
+const fileUrl = ref(props.src)
+const visible = ref(false)
+const loading = ref(false)
+const fileName = ref("")
+const fileExt = ref(props.fileExt)
+
+function formatUrl(url: string) {
+	if (isExternal(url)) {
+		return url
+	}
+
+	// 确保URL以"/"结尾
+	let baseUrl = import.meta.env.VITE_APP_BASE_API
+	if (baseUrl && !baseUrl.endsWith("/")) {
+		baseUrl += "/"
+	}
+
+	// 确保prefixSrc以"/"开头,但不以"/"结尾
+	let prefix = props.prefixSrc ? props.prefixSrc : ""
+	if (prefix && !prefix.startsWith("/")) {
+		prefix = "/" + prefix
+	}
+	if (prefix && prefix.endsWith("/")) {
+		prefix = prefix.slice(0, -1)
+	}
+
+	// 确保url以"/"开头
+	if (!url.startsWith("/")) {
+		url = "/" + url
+	}
+
+	url = baseUrl + prefix + url
+	if (url.includes("?")) {
+		url += "&inline=true"
+	} else {
+		url += "?inline=true"
+	}
+	return url
+}
+
+function load(fileId?: string, src?: string) {
+	// 清理之前的对象URL
+	if (fileUrl.value && fileUrl.value.startsWith("blob:")) {
+		URL.revokeObjectURL(fileUrl.value)
+		fileUrl.value = ""
+	}
+	const targetFileId = fileId || props.fileId
+	const targetSrc = src || props.src
+
+	if (!targetSrc && !targetFileId) {
+		fileUrl.value = ""
+		return
+	}
+
+	if (props.previewUrl && targetFileId) {
+		let requestUrl = props.previewUrl
+		if (!requestUrl.endsWith("/")) {
+			requestUrl += "/"
+		}
+
+		loading.value = true
+		Rs.get({
+			url: requestUrl + targetFileId,
+			loading: false,
+			responseType: "blob"
+		})
+			.then((res: any) => {
+				loading.value = false
+				if (res.type === "application/json") {
+					return
+				}
+				const data = new Blob([res], { type: `application/${fileExt.value}` })
+				fileUrl.value = URL.createObjectURL(data)
+			})
+			.catch(() => {
+				loading.value = false
+			})
+	} else if (targetFileId) {
+		fileUrl.value = formatUrl(targetFileId)
+	} else {
+		fileUrl.value = formatUrl(targetSrc || "")
+	}
+}
+
+function init() {
+	load(props.fileId, props.src)
+}
+
+function open(options: { fileId?: string; src?: string; fileName?: string; fileExt?: string }) {
+	visible.value = true
+	if (options.fileName) {
+		if (!options.fileExt) {
+			fileExt.value = options.fileName.split(".").pop()
+		}
+		fileName.value = options.fileName
+	}
+	load(options.fileId, options.src)
+}
+
+function close() {
+	visible.value = false
+	// 清理对象URL以释放内存
+	if (fileUrl.value && fileUrl.value.startsWith("blob:")) {
+		URL.revokeObjectURL(fileUrl.value)
+		fileUrl.value = ""
+	}
+	fileName.value = props.fileName
+	fileExt.value = props.fileExt
+}
+
+onMounted(init)
+
+defineExpose({
+	open,
+	close
+})
+</script>
+
+<template>
+	<div v-if="visible" class="pdf-preview-container">
+		<div class="pdf-preview-overlay">
+			<div class="pdf-preview-header">
+				<button class="close-btn" @click="close">✕</button>
+				<div v-if="fileName" class="file-name">{{ fileName }}</div>
+			</div>
+			<div class="pdf-preview-content">
+				<div v-if="loading" class="loading-container">
+					<div class="loading-spinner"></div>
+					<p>文件加载中...</p>
+				</div>
+				<template v-else>
+					<object
+						v-if="fileUrl"
+						:data="fileUrl"
+						:type="`application/${fileExt}`"
+						class="office-object">
+						<p style="margin-top: -20%">
+							您的浏览器不支持
+							<span class="px-2 text-danger">{{ fileExt.toUpperCase() }}</span>
+							文件预览,请
+							<a :href="fileUrl" :download="fileName">下载文件</a>
+							查看。
+						</p>
+					</object>
+					<div v-else class="no-preview">无预览内容</div>
+				</template>
+			</div>
+		</div>
+	</div>
+</template>
+
+<style scoped>
+.pdf-preview-container {
+	position: fixed;
+	top: 0;
+	left: 0;
+	width: 100vw;
+	height: 100vh;
+	z-index: 9999;
+}
+
+.pdf-preview-overlay {
+	width: 100%;
+	height: 100%;
+	background: rgba(0, 0, 0, 0.5);
+	display: flex;
+	flex-direction: column;
+}
+
+.pdf-preview-header {
+	height: 50px;
+	padding: 10px;
+	background: white;
+	position: relative;
+	display: flex;
+	align-items: center;
+}
+
+.close-btn {
+	width: 30px;
+	height: 30px;
+	border: none;
+	background: #f2f2f2;
+	border-radius: 50%;
+	font-size: 16px;
+	cursor: pointer;
+	display: flex;
+	align-items: center;
+	justify-content: center;
+	margin-right: 10px;
+}
+
+.close-btn:hover {
+	background: #e0e0e0;
+}
+
+.file-name {
+	font-size: 16px;
+	font-weight: 600;
+	color: var(--bs-primary);
+	flex: 1;
+	white-space: nowrap;
+	overflow: hidden;
+	text-overflow: ellipsis;
+	padding-right: 20px;
+}
+
+.pdf-preview-content {
+	flex: 1;
+	background: white;
+	padding: 10px;
+	position: relative;
+}
+
+.office-object {
+	width: 100%;
+	height: 100%;
+	display: flex;
+	justify-content: center;
+	align-items: center;
+}
+
+.no-preview {
+	display: flex;
+	align-items: center;
+	justify-content: center;
+	height: 100%;
+	color: #999;
+	font-size: 14px;
+}
+
+.loading-container {
+	position: absolute;
+	top: 0;
+	left: 0;
+	width: 100%;
+	height: 100%;
+	display: flex;
+	flex-direction: column;
+	align-items: center;
+	justify-content: center;
+	background: rgba(255, 255, 255, 0.8);
+	z-index: 10;
+}
+
+.loading-spinner {
+	width: 40px;
+	height: 40px;
+	border: 4px solid #f3f3f3;
+	border-top: 4px solid #409eff;
+	border-radius: 50%;
+	animation: spin 1s linear infinite;
+}
+
+@keyframes spin {
+	0% {
+		transform: rotate(0deg);
+	}
+	100% {
+		transform: rotate(360deg);
+	}
+}
+</style>

+ 97 - 45
UI/VAP_V3.VUE/src/components/upload/VbUpload.vue

@@ -2,7 +2,7 @@
 const props = withDefaults(
 	defineProps<{
 		modelValue: string | any[]
-		prefixUrl?: string
+		previewPrefixUrl?: string
 		uploadType?: "image" | "file"
 		uploadUrl?: string
 		limit?: number // 图片数量限制
@@ -12,7 +12,8 @@ const props = withDefaults(
 	}>(),
 	{
 		uploadType: "image",
-		prefixUrl: "/resource/oss/",
+		uploadUrl: "resource/oss/upload",
+		previewPrefixUrl: "resource/oss/preview/",
 		limit: 5,
 		fileSize: 5,
 		fileType: () => ["png", "jpg", "jpeg"],
@@ -25,13 +26,17 @@ const emits = defineEmits<{
 	(e: "delete", v: any): void
 }>()
 const imageUploadRef = ref()
+const pdfPreviewRef = ref()
+const notInit = ref(false)
 const number = ref(0)
 const uploadList = ref<any[]>([])
 const showViewer = ref(false)
 const viewerInitialIndex = ref(0)
 const baseUrl = import.meta.env.VITE_APP_BASE_API
-const uploadImgUrl = ref(
-	import.meta.env.VITE_APP_BASE_API + (props.uploadUrl ? props.uploadUrl : "/resource/oss/upload")
+const uploadImgFileUrl = ref(
+	import.meta.env.VITE_APP_BASE_API +
+		"/" +
+		(props.uploadUrl ? props.uploadUrl : "resource/oss/upload")
 ) // 上传的图片服务器地址
 const headers = ref({
 	Authorization: "Bearer " + getToken(),
@@ -92,20 +97,21 @@ function handleUploadError() {
 
 // 上传成功回调
 function handleUploadSuccess(res: any, file: any) {
-	//console.log("RES", res, file)
 	if (res.code === 200 && res.data.url) {
-		let url = `${baseUrl}/${
-			props.prefixUrl
-				? props.prefixUrl.lastIndexOf("/") == props.prefixUrl.length - 1
-					? props.prefixUrl
-					: props.prefixUrl + "/"
-				: ""
-		}${res.data.url}`
+		let url = `/${props.previewPrefixUrl ? props.previewPrefixUrl : ""}/${res.data.url}`
+		url = url
+			.replace("https://", "$$$$")
+			.replace("http://", "@@@@")
+			.replace(/\/\//g, "/")
+			.replace("$$$$", "https://")
+			.replace("@@@@", "http://")
 		uploadList.value.push({
-			id: res.data.ossId,
-			name: res.data.url,
 			realUrl: url,
-			url: file.url
+			url: file.url,
+			name: res.data.fileId,
+			fileName: res.data.fileName,
+			fileId: res.data.fileId,
+			ossId: res.data.ossId
 		})
 		uploadedSuccessfully()
 	} else {
@@ -144,6 +150,7 @@ function uploadedSuccessfully() {
 		uploadList.value = []
 		number.value = 0
 		emits("update:modelValue", listToString(fileList.value))
+		notInit.value = true
 		message.closeLoading()
 	}
 }
@@ -151,18 +158,14 @@ function uploadedSuccessfully() {
 // 预览
 function handlePictureCardPreview(file: any) {
 	//dialogImageUrl.value = file.url
-	console.log("---", file)
 	viewerInitialIndex.value = file.index //fileList.value.map((f) => f.url).indexOf(file.url)
 	showViewer.value = true
 }
-
-// 获取文件名称
-function getFileName(name: string) {
-	if (name.lastIndexOf("/") > -1) {
-		return name.slice(name.lastIndexOf("/") + 1)
-	} else {
-		return ""
-	}
+function handlePreviewPdf(fileId: string, fileName: string) {
+	pdfPreviewRef.value.open({
+		fileId,
+		fileName
+	})
 }
 
 // 对象转成指定字符串分隔
@@ -170,8 +173,8 @@ function listToString(list: any[], separator?: string) {
 	let str = ""
 	separator = separator ?? ","
 	list.forEach((v) => {
-		if (undefined !== v.url && v.url.indexOf("blob:") !== 0) {
-			str += v.name + separator
+		if (undefined !== v.fileId) {
+			str += `${v.fileId}$${v.fileName}$${v.ossId}` + separator
 		}
 	})
 	return str != "" ? str.substring(0, str.length - 1) : ""
@@ -179,26 +182,48 @@ function listToString(list: any[], separator?: string) {
 
 function initFile(files: any) {
 	if (files) {
+		let temp = 0
 		// 首先将值转为数组
 		let list = Array.isArray(files) ? files : (files as string)?.split(",")
 		// 然后将数组转为对象数组
 		list = list.map((item) => {
 			if (typeof item === "string") {
-				if (!item.includes(baseUrl)) {
-					let url = `${baseUrl}/${
-						props.prefixUrl
-							? props.prefixUrl.lastIndexOf("/") == props.prefixUrl.length - 1
-								? props.prefixUrl
-								: props.prefixUrl + "/"
-							: ""
-					}${item}`
-					item = {
-						name: item,
-						realUrl: url
+				const itemArr = item.split("$")
+				if (itemArr.length == 1) {
+					itemArr.push("01.pdf")
+				}
+				if (itemArr.length == 2) {
+					itemArr.push("0")
+				}
+				item = {
+					fileId: itemArr[0],
+					fileName: itemArr[1],
+					ossId: itemArr[2],
+					index: temp
+				}
+				if (!itemArr[0].includes(baseUrl)) {
+					let url = `/${props.previewPrefixUrl ? props.previewPrefixUrl : ""}/${itemArr[0]}`
+					url = url
+						.replace("https://", "$$$$")
+						.replace("http://", "@@@@")
+						.replace(/\/\//g, "/")
+						.replace("$$$$", "https://")
+						.replace("@@@@", "http://")
+					if (isImage.value) {
+						item.realUrl = url
+					} else {
+						item.url = url
 					}
+					item.name = itemArr[0]
 				} else {
-					item = { name: item, url: item }
+					if (isImage.value) {
+						item.realUrl = itemArr[0]
+					} else {
+						item.url = itemArr[0]
+					}
+					item.name = itemArr[0]
 				}
+				item.uid = item.uid || new Date().getTime() + temp++
 			}
 			return item
 		})
@@ -206,21 +231,29 @@ function initFile(files: any) {
 		list.forEach((v: any) => {
 			if (v.url) {
 				fileList.value.push({
+					index: v.index,
+					fileId: v.fileId,
+					fileName: v.fileName,
+					ossId: v.ossId,
 					name: v.name,
 					realUrl: v.realUrl,
 					url: v.url
 				})
 			} else {
-				RequestService.get({ url: v.realUrl, loading: false, responseType: "blob" }).then((res) => {
-					if (res.status === 200) {
-						const blobUrl = URL.createObjectURL(res.data)
+				Rs.get(v.realUrl, { url: v.realUrl, loading: false, responseType: "blob" }).then(
+					(res: any) => {
+						const blobUrl = URL.createObjectURL(res)
 						fileList.value.push({
+							index: v.index,
+							fileId: v.fileId,
+							fileName: v.fileName,
+							ossId: v.ossId,
 							name: v.name,
 							url: blobUrl,
 							realUrl: v.realUrl
 						})
 					}
-				})
+				)
 			}
 		})
 	} else {
@@ -233,13 +266,23 @@ function init() {
 }
 
 onMounted(init)
+watch(
+	() => props.modelValue,
+	(val) => {
+		if (notInit.value) {
+			notInit.value = false
+			return
+		}
+		initFile(val)
+	}
+)
 </script>
 <template>
 	<div class="component-upload-image">
 		<el-upload
 			multiple
 			ref="imageUploadRef"
-			:action="uploadImgUrl"
+			:action="uploadImgFileUrl"
 			:list-type="isImage ? 'picture-card' : undefined"
 			:file-list="fileList"
 			:limit="limit"
@@ -291,14 +334,15 @@ onMounted(init)
 				:key="file.uid"
 				class="el-upload-list__item ele-upload-list__item-content"
 				v-for="(file, index) in fileList">
-				<el-link :href="`${baseUrl}${file.url}`" :underline="false" target="_blank">
-					<span class="el-icon-document">{{ getFileName(file.name) }}</span>
+				<el-link href="#" :underline="false" @click="handlePreviewPdf(file.fileId, file.fileName)">
+					<span class="el-icon-document">{{ file.fileName }}</span>
 				</el-link>
 				<div class="ele-upload-list__item-content-action">
 					<el-link :underline="false" @click="handleDeleteFile(index)" type="danger">删除</el-link>
 				</div>
 			</li>
 		</transition-group>
+		<VbPdfPreview ref="pdfPreviewRef" />
 	</div>
 </template>
 
@@ -307,6 +351,14 @@ onMounted(init)
 :deep(.hide .el-upload--picture-card) {
 	display: none;
 }
+:deep(.el-link) {
+	width: 100%;
+	justify-content: flex-start;
+	.el-link__inner {
+		padding: 0 10px;
+		color: var(--bs-primary);
+	}
+}
 
 .upload-file-uploader {
 	margin-bottom: 5px;