Parcourir la source

Update 优化前端资源展示的组件,重构用户头像(修改为字符串)

获取资源在统一应地方utils\resource.ts
Yue il y a 2 jours
Parent
commit
37eb93740e
18 fichiers modifiés avec 153 ajouts et 104 suppressions
  1. 1 1
      SERVER/VberAdminPlusV3/.script/docker/sql/init.sql
  2. 1 1
      SERVER/VberAdminPlusV3/.script/sql/admin.sql
  3. 2 2
      SERVER/VberAdminPlusV3/vber-admin/src/main/resources/application-dev.yml
  4. 3 3
      SERVER/VberAdminPlusV3/vber-modules/vber-system/src/main/java/com/vber/system/controller/system/SysProfileController.java
  5. 1 1
      SERVER/VberAdminPlusV3/vber-modules/vber-system/src/main/java/com/vber/system/domain/SysUser.java
  6. 1 2
      SERVER/VberAdminPlusV3/vber-modules/vber-system/src/main/java/com/vber/system/domain/vo/ProfileUserVo.java
  7. 1 2
      SERVER/VberAdminPlusV3/vber-modules/vber-system/src/main/java/com/vber/system/domain/vo/SysUserVo.java
  8. 1 0
      SERVER/VberAdminPlusV3/vber-modules/vber-system/src/main/java/com/vber/system/service/ISysOssService.java
  9. 1 1
      SERVER/VberAdminPlusV3/vber-modules/vber-system/src/main/java/com/vber/system/service/ISysUserService.java
  10. 6 5
      SERVER/VberAdminPlusV3/vber-modules/vber-system/src/main/java/com/vber/system/service/impl/SysOssServiceImpl.java
  11. 1 1
      SERVER/VberAdminPlusV3/vber-modules/vber-system/src/main/java/com/vber/system/service/impl/SysUserServiceImpl.java
  12. 9 15
      UI/VAP_V3.VUE/src/components/symbol/VbSymbol.vue
  13. 7 27
      UI/VAP_V3.VUE/src/components/upload/VbImagePreview.vue
  14. 5 26
      UI/VAP_V3.VUE/src/components/upload/VbOfficePreview.vue
  15. 103 0
      UI/VAP_V3.VUE/src/core/utils/resource.ts
  16. 1 6
      UI/VAP_V3.VUE/src/layouts/main/header/navbar/UserAccountMenu.vue
  17. 8 9
      UI/VAP_V3.VUE/src/views/account/profile/_avatar.vue
  18. 1 2
      UI/VAP_V3.VUE/src/views/system/oss/index.vue

+ 1 - 1
SERVER/VberAdminPlusV3/.script/docker/sql/init.sql

@@ -44,7 +44,7 @@ CREATE TABLE sys_user
     email       VARCHAR(50)  DEFAULT '' COMMENT '用户邮箱',
     phonenumber VARCHAR(11)  DEFAULT '' COMMENT '手机号码',
     sex         CHAR(1)      DEFAULT '0' COMMENT '用户性别(0男 1女 2未知)',
-    avatar      BIGINT(20) COMMENT '头像地址',
+    avatar      VARCHAR(50) COMMENT '头像地址',
     password    VARCHAR(100) DEFAULT '' COMMENT '密码',
     status      CHAR(1)      DEFAULT '0' COMMENT '账号状态(0正常 1停用)',
     del_flag    CHAR(1)      DEFAULT '0' COMMENT '删除标志(0代表存在 2代表删除)',

+ 1 - 1
SERVER/VberAdminPlusV3/.script/sql/admin.sql

@@ -44,7 +44,7 @@ CREATE TABLE sys_user
     email       VARCHAR(50)  DEFAULT '' COMMENT '用户邮箱',
     phonenumber VARCHAR(11)  DEFAULT '' COMMENT '手机号码',
     sex         CHAR(1)      DEFAULT '0' COMMENT '用户性别(0男 1女 2未知)',
-    avatar      BIGINT(20) COMMENT '头像地址',
+    avatar      VARCHAR(50) COMMENT '头像地址',
     password    VARCHAR(100) DEFAULT '' COMMENT '密码',
     status      CHAR(1)      DEFAULT '0' COMMENT '账号状态(0正常 1停用)',
     del_flag    CHAR(1)      DEFAULT '0' COMMENT '删除标志(0代表存在 2代表删除)',

+ 2 - 2
SERVER/VberAdminPlusV3/vber-admin/src/main/resources/application-dev.yml

@@ -23,9 +23,9 @@ spring:
           driverClassName: com.mysql.cj.jdbc.Driver
           # jdbc 所有参数配置参考 https://lionli.blog.csdn.net/article/details/122018562
           # rewriteBatchedStatements=true 批处理优化 大幅提升批量插入更新删除性能(对数据库有性能损耗 使用批量操作应考虑性能问题)
-          url: jdbc:mysql://192.168.0.104:3316/VberAdminPlusV3?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8&autoReconnect=true&rewriteBatchedStatements=true&allowPublicKeyRetrieval=true&nullCatalogMeansCurrent=true
+          url: jdbc:mysql://192.168.0.104:3317/VberAdminPlusV3DB?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8&autoReconnect=true&rewriteBatchedStatements=true&allowPublicKeyRetrieval=true&nullCatalogMeansCurrent=true
           username: root
-          password: 123456
+          password: root
         #        # 从库数据源
         #        slave:
         #          lazy: true

+ 3 - 3
SERVER/VberAdminPlusV3/vber-modules/vber-system/src/main/java/com/vber/system/controller/system/SysProfileController.java

@@ -123,9 +123,9 @@ public class SysProfileController extends BaseController {
                 return R.fail("文件格式不正确,请上传" + Arrays.toString(MimeTypeUtils.IMAGE_EXTENSION) + "格式");
             }
             SysOssVo oss = ossService.upload(avatarfile, "avatar");
-            String avatar = oss.getUrl();
+            String avatar = oss.getObjectId();
             boolean updateSuccess = DataPermissionHelper
-                    .ignore(() -> userService.updateUserAvatar(LoginHelper.getUserId(), oss.getOssId()));
+                    .ignore(() -> userService.updateUserAvatar(LoginHelper.getUserId(), oss.getObjectId()));
             if (updateSuccess) {
                 return R.ok(new AvatarVo(avatar));
             }
@@ -133,7 +133,7 @@ public class SysProfileController extends BaseController {
         return R.fail("上传图片异常,请联系管理员");
     }
 
-    public record AvatarVo(String imgUrl) {
+    public record AvatarVo(String objectId) {
     }
 
     public record ProfileVo(ProfileUserVo user, String roleGroup, String postGroup) {

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

@@ -65,7 +65,7 @@ public class SysUser extends TenantEntity {
     /**
      * 用户头像
      */
-    private Long avatar;
+    private String avatar;
 
     /**
      * 密码

+ 1 - 2
SERVER/VberAdminPlusV3/vber-modules/vber-system/src/main/java/com/vber/system/domain/vo/ProfileUserVo.java

@@ -68,8 +68,7 @@ public class ProfileUserVo implements Serializable {
     /**
      * 头像地址
      */
-    @Translation(type = TransConstant.OSS_ID_TO_URL)
-    private Long avatar;
+    private String avatar;
 
     /**
      * 最后登录IP

+ 1 - 2
SERVER/VberAdminPlusV3/vber-modules/vber-system/src/main/java/com/vber/system/domain/vo/SysUserVo.java

@@ -77,8 +77,7 @@ public class SysUserVo implements Serializable {
     /**
      * 头像地址
      */
-    @Translation(type = TransConstant.OSS_ID_TO_URL)
-    private Long avatar;
+    private String avatar;
 
     /**
      * 密码

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

@@ -19,6 +19,7 @@ import java.util.List;
  */
 public interface ISysOssService {
 
+
     /**
      * 查询OSS对象存储列表
      *

+ 1 - 1
SERVER/VberAdminPlusV3/vber-modules/vber-system/src/main/java/com/vber/system/service/ISysUserService.java

@@ -201,7 +201,7 @@ public interface ISysUserService {
      * @param avatar 头像地址
      * @return 结果
      */
-    boolean updateUserAvatar(Long userId, Long avatar);
+    boolean updateUserAvatar(Long userId, String avatar);
 
     /**
      * 重置用户密码

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

@@ -3,11 +3,11 @@ package com.vber.system.service.impl;
 import cn.hutool.core.bean.BeanUtil;
 import cn.hutool.core.convert.Convert;
 import cn.hutool.core.io.IoUtil;
-import cn.hutool.core.util.IdUtil;
 import cn.hutool.core.util.ObjectUtil;
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.baomidou.mybatisplus.core.toolkit.Wrappers;
 import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.vber.common.core.constant.CacheNames;
 import com.vber.common.core.domain.dto.OssDTO;
 import com.vber.common.core.exception.ServiceException;
 import com.vber.common.core.service.OssService;
@@ -34,6 +34,7 @@ import com.vber.system.service.ISysOssService;
 import jakarta.servlet.http.HttpServletResponse;
 import lombok.RequiredArgsConstructor;
 import org.jetbrains.annotations.NotNull;
+import org.springframework.cache.annotation.Cacheable;
 import org.springframework.http.HttpHeaders;
 import org.springframework.http.MediaType;
 import org.springframework.http.ResponseEntity;
@@ -60,7 +61,7 @@ public class SysOssServiceImpl implements ISysOssService, OssService {
 
     private final SysOssMapper baseMapper;
 
-    private final String[] SysFileSuffix = new String[] { "avatar", "system" };
+    private final String[] SysFileSuffix = new String[]{"avatar", "system"};
 
     /**
      * 查询OSS对象存储列表
@@ -177,7 +178,7 @@ public class SysOssServiceImpl implements ISysOssService, OssService {
      * @param ossId 文件在数据库中的唯一标识
      * @return SysOssVo 对象,包含文件信息
      */
-    // @Cacheable(cacheNames = CacheNames.SYS_OSS, key = "#ossId")
+    @Cacheable(cacheNames = CacheNames.SYS_OSS, key = "#ossId")
     @Override
     public SysOssVo getById(Long ossId) {
         return baseMapper.selectVoById(ossId);
@@ -189,7 +190,7 @@ public class SysOssServiceImpl implements ISysOssService, OssService {
      * @param objectId 文件唯一标识
      * @return SysOssVo 对象,包含文件信息
      */
-    // @Cacheable(cacheNames = CacheNames.SYS_OSS, key = "#objectId")
+    @Cacheable(cacheNames = CacheNames.SYS_OSS, key = "#objectId")
     @Override
     public SysOssVo getByObjectId(String objectId) {
         TenantHelper.enableIgnore();
@@ -340,7 +341,7 @@ public class SysOssServiceImpl implements ISysOssService, OssService {
 
     @NotNull
     private SysOssVo buildResultEntity(String originalFileName, String suffix, String configKey,
-            PutObjectResult uploadResult, SysOssExt ext1) {
+                                       PutObjectResult uploadResult, SysOssExt ext1) {
         SysOss oss = new SysOss();
         oss.setUrl(uploadResult.url());
         oss.setFileSuffix(suffix);

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

@@ -429,7 +429,7 @@ public class SysUserServiceImpl implements ISysUserService, UserService {
      * @return 结果
      */
     @Override
-    public boolean updateUserAvatar(Long userId, Long avatar) {
+    public boolean updateUserAvatar(Long userId, String avatar) {
         return baseMapper.update(null,
                 new LambdaUpdateWrapper<SysUser>()
                         .set(SysUser::getAvatar, avatar)

+ 9 - 15
UI/VAP_V3.VUE/src/components/symbol/VbSymbol.vue

@@ -4,7 +4,6 @@ const props = withDefaults(
 	defineProps<{
 		text?: string
 		src?: string
-		url?: string
 		size?: number
 		shape?: "" | "circle" | "square"
 		isRatio?: boolean
@@ -42,25 +41,20 @@ const _style = computed(() => {
 })
 const srcUrl = ref<string>("")
 function init() {
-	if (props.url && props.src) {
-		Rs.get({
-			url: props.url + props.src,
-			loading: false,
-			responseType: "blob"
-		}).then((res: any) => {
-			if (res.type === "application/json") {
-				return
-			}
-			const data = new Blob([res])
-			const url = URL.createObjectURL(data)
-			srcUrl.value = url
-		})
-	} else if (props.src) {
+	if (!props.src) {
+		return
+	}
+	if (props.src.startsWith("http") || props.src.includes("/")) {
 		srcUrl.value = props.src
+		return
 	}
+	getResourceLocalUrl(props.src).then((url) => {
+		srcUrl.value = url
+	})
 }
 
 onMounted(init)
+watch(() => props.src, init)
 </script>
 
 <template>

+ 7 - 27
UI/VAP_V3.VUE/src/components/upload/VbImagePreview.vue

@@ -3,12 +3,10 @@ import Rs from "@@/services/RequestService"
 const props = withDefaults(
 	defineProps<{
 		src: string
-		prefixSrc?: string
 		fit?: "" | "fill" | "none" | "contain" | "cover" | "scale-down"
 		width?: string | number
 		height?: string | number
 		imageStyle?: any
-		url?: string
 		load?: (e: any) => void
 		error?: (e: any) => void
 	}>(),
@@ -16,7 +14,6 @@ const props = withDefaults(
 		width: 75,
 		height: 50,
 		prefixSrc: "/resource/oss/preview/",
-		url: "/resource/oss/preview/",
 		fit: "cover",
 		imageStyle: () => {
 			return {
@@ -25,13 +22,7 @@ const props = withDefaults(
 		}
 	}
 )
-function formatUrl(url: string) {
-	if (isExternal(url)) {
-		return url
-	}
-	url = import.meta.env.VITE_APP_BASE_API + (props.prefixSrc ? props.prefixSrc : "") + url
-	return url
-}
+
 const srcList = ref<any>([])
 
 const imgStyle = computed(() => {
@@ -51,24 +42,13 @@ function load() {
 		return
 	}
 	const src_list = props.src.split(",")
-	src_list.forEach((v) => {
-		if (props.url) {
-			Rs.get({
-				url: props.url + v.split("$")[0],
-				loading: false,
-				responseType: "blob"
-			}).then((res: any) => {
-				if (res.type === "application/json") {
-					return
-				}
-				const data = new Blob([res])
-				const url = URL.createObjectURL(data)
-				srcList.value.push(url)
-			})
-		} else {
-			const url = formatUrl(v)
-			srcList.value.push(url)
+	src_list.forEach(async (v) => {
+		if (v.startsWith("http") || v.includes("/")) {
+			srcList.value.push(v)
+			return
 		}
+		const url = await getResourceLocalUrl(v)
+		srcList.value.push(url)
 	})
 }
 

+ 5 - 26
UI/VAP_V3.VUE/src/components/upload/VbOfficePreview.vue

@@ -5,7 +5,6 @@ import { isExternal } from "@@/utils/validate"
 const props = withDefaults(
 	defineProps<{
 		src?: string
-		previewUrl?: string
 		fileId?: string
 		fileName?: string
 		fileExt?: string
@@ -73,32 +72,12 @@ function load(fileId?: string, src?: string) {
 		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) {
+	if (targetFileId.startsWith("http") || targetFileId.includes("/")) {
 		fileUrl.value = formatUrl(targetFileId)
+	} else if (targetFileId) {
+		getResourceLocalUrl(targetFileId).then((url) => {
+			fileUrl.value = url
+		})
 	} else {
 		fileUrl.value = formatUrl(targetSrc || "")
 	}

+ 103 - 0
UI/VAP_V3.VUE/src/core/utils/resource.ts

@@ -0,0 +1,103 @@
+import Rs from "@@/services/RequestService"
+
+// 资源缓存字典:objectId -> objectUrl
+const ResourceDict = new Map<string, string>()
+
+/**
+ * 缓存最大数量限制
+ * 当缓存超过此数量时,会自动清理最旧的缓存项
+ */
+export const MAX_RESOURCE_CACHE_SIZE = 100
+
+/**
+ * 获取资源本地 URL(带缓存)
+ * @param objectId - 资源对象 ID
+ * @returns 资源的 Blob URL,失败返回 undefined
+ */
+export async function getResourceLocalUrl(objectId: string): Promise<string | undefined> {
+	// 1. 检查缓存
+	if (ResourceDict.has(objectId)) {
+		return ResourceDict.get(objectId)
+	}
+
+	// 2. 创建新的请求
+	const requestPromise = (async () => {
+		try {
+			const url = `/resource/oss/preview/${objectId}`
+			const res: any = await Rs.get({
+				url,
+				loading: false,
+				responseType: "blob"
+			})
+
+			// 检查响应类型
+			if (res.type === "application/json") {
+				console.warn(`[Resource] 获取资源失败: ${objectId}`)
+				return undefined
+			}
+
+			// 创建 Blob URL
+			const data = new Blob([res])
+			const objectUrl = URL.createObjectURL(data)
+
+			// 存入缓存
+			ResourceDict.set(objectId, objectUrl)
+
+			// 检查缓存大小,超过限制时清理最旧的缓存
+			if (ResourceDict.size > MAX_RESOURCE_CACHE_SIZE) {
+				cleanupOldestCache()
+			}
+
+			return objectUrl
+		} catch (error) {
+			console.error(`[Resource] 获取资源异常: ${objectId}`, error)
+			return undefined
+		}
+	})()
+
+	return requestPromise
+}
+
+/**
+ * 清理最旧的缓存项(LRU 策略)
+ * Map 的迭代顺序是插入顺序,第一个是最旧的
+ */
+function cleanupOldestCache(): void {
+	// 获取第一个(最旧的)缓存项
+	const oldestKey = ResourceDict.keys().next().value
+	if (oldestKey) {
+		const oldestUrl = ResourceDict.get(oldestKey)
+		if (oldestUrl) {
+			URL.revokeObjectURL(oldestUrl) // 释放 Blob URL
+		}
+		ResourceDict.delete(oldestKey)
+		console.log(`[Resource] 缓存已满,清理最旧缓存: ${oldestKey}`)
+	}
+}
+
+/**
+ * 清除指定资源的缓存并释放内存
+ * @param objectId - 资源对象 ID,不传则清空所有缓存
+ */
+export function clearResourceCache(objectId?: string): void {
+	if (objectId) {
+		const url = ResourceDict.get(objectId)
+		if (url) {
+			URL.revokeObjectURL(url) // 释放 Blob URL
+			ResourceDict.delete(objectId)
+		}
+	} else {
+		// 清空所有缓存
+		ResourceDict.forEach((url) => {
+			URL.revokeObjectURL(url)
+		})
+		ResourceDict.clear()
+	}
+}
+
+/**
+ * 获取缓存大小
+ */
+export function getResourceCacheSize(): number {
+	return ResourceDict.size
+}

+ 1 - 6
UI/VAP_V3.VUE/src/layouts/main/header/navbar/UserAccountMenu.vue

@@ -37,12 +37,7 @@ function signOut() {
 			data-vb-menu-trigger="{default: 'click', lg: 'hover'}"
 			data-vb-menu-attach="parent"
 			data-vb-menu-placement="bottom-end">
-			<VbSymbol
-				url="/resource/oss"
-				:src="user.avatar"
-				:text="user.userName"
-				:size="40"
-				alt="user" />
+			<VbSymbol :src="user.avatar" :text="user.userName" :size="40" alt="user" />
 		</div>
 		<div
 			class="menu menu-sub menu-sub-dropdown menu-column menu-rounded menu-gray-600 menu-state-bg-light-primary py-4 fs-6 w-275px"

+ 8 - 9
UI/VAP_V3.VUE/src/views/account/profile/_avatar.vue

@@ -7,10 +7,10 @@ const user = appStore.authStore.user
 const avatarModalRef = ref()
 const cropperRef = ref()
 const visible = ref(false)
-
+const avatar = computed(() => user.avatar)
 // 图片裁剪数据
 const options = reactive<any>({
-	img: user.avatar, // 裁剪图片的地址
+	img: avatar.value, // 裁剪图片的地址
 	autoCrop: true, // 是否默认生成截图框
 	autoCropWidth: 200, // 默认生成截图框宽度
 	autoCropHeight: 200, // 默认生成截图框高度
@@ -62,9 +62,9 @@ function uploadImg() {
 	cropperRef.value.getCropBlob((data: any) => {
 		const formData = new FormData()
 		formData.append("avatar", data, options.fileName)
-		apis.system.userApi.uploadAvatar(formData).then((res: any) => {
-			appStore.authStore.changeAvatar(res.imgUrl)
-			options.img = appStore.authStore.user.avatar
+		apis.system.userApi.uploadAvatar(formData).then(async (res: any) => {
+			appStore.authStore.changeAvatar(res.objectId)
+			options.img = await getResourceLocalUrl(res.objectId)
 			message.msgSuccess("修改成功")
 			avatarModalRef.value.hide()
 		})
@@ -75,12 +75,12 @@ function realTime(data: any) {
 	options.previews = data
 }
 /** 打开弹出层结束时的回调 */
-function modalOpened() {
+async function modalOpened() {
+	options.img = await getResourceLocalUrl(avatar.value)
 	visible.value = true
 }
 /** 关闭窗口 */
 function modalHide() {
-	options.img = user.avatar
 	visible.value = false
 }
 const previewDivStyle = computed(() => {
@@ -94,8 +94,7 @@ const previewDivStyle = computed(() => {
 			<VbSymbol
 				class="position-relative cursor-pointer"
 				:size="100"
-				url="/resource/oss"
-				:src="user.avatar"
+				:src="avatar"
 				:text="user.userName"
 				@click="avatarModalRef.show()"></VbSymbol>
 		</vb-tooltip>

+ 1 - 2
UI/VAP_V3.VUE/src/views/system/oss/index.vue

@@ -252,8 +252,7 @@ getServiceOptions()
 			<template #url="{ row }">
 				<template v-if="checkFileType(row.originalName)">
 					<VbImagePreview
-						url="/resource/oss/preview"
-						:src="`/${row.objectId}`"
+						:src="row.objectId"
 						:image-style="{ marginTop: '6px', marginBottom: '' }"></VbImagePreview>
 				</template>
 				<span v-else>