index.vue 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362
  1. <template>
  2. <el-image
  3. :src="realSrc"
  4. fit="cover"
  5. :style="`width:${realWidth};height:${realHeight};`"
  6. :preview-src-list="previewDisabled ? [] : realSrcList"
  7. preview-teleported
  8. v-loading="loading"
  9. @error="handleImageError"
  10. @load="handleImageLoad"
  11. >
  12. <template #error>
  13. <div class="image-slot">
  14. <el-icon><picture-filled /></el-icon>
  15. <div class="error-text">图片加载失败</div>
  16. </div>
  17. </template>
  18. </el-image>
  19. </template>
  20. <script setup lang="ts">
  21. import { computed, ref, watch, onBeforeUnmount } from 'vue';
  22. import { listByIds } from '@/api/system/oss';
  23. import type { OssVO } from '@/api/system/oss/types';
  24. import request from '@/utils/request';
  25. const props = defineProps({
  26. src: {
  27. type: String,
  28. default: ''
  29. },
  30. width: {
  31. type: [Number, String],
  32. default: ''
  33. },
  34. height: {
  35. type: [Number, String],
  36. default: ''
  37. },
  38. // 是否为ossid模式
  39. isOssId: {
  40. type: Boolean,
  41. default: true
  42. },
  43. // 是否禁用预览功能
  44. previewDisabled: {
  45. type: Boolean,
  46. default: false
  47. }
  48. });
  49. const loading = ref(false);
  50. const urlCache = ref<Map<string, string>>(new Map());
  51. const blobUrls = ref<string[]>([]);
  52. // 图片加载成功事件
  53. const handleImageLoad = () => {
  54. // 图片加载成功
  55. };
  56. // 图片加载失败事件
  57. const handleImageError = (error: any) => {
  58. // 图片加载失败处理
  59. };
  60. // 清理blob URL,防止内存泄漏
  61. const cleanupBlobUrls = () => {
  62. blobUrls.value.forEach((url) => {
  63. URL.revokeObjectURL(url);
  64. });
  65. blobUrls.value = [];
  66. urlCache.value.clear();
  67. };
  68. // 组件卸载时清理blob URLs
  69. onBeforeUnmount(() => {
  70. cleanupBlobUrls();
  71. });
  72. // 验证blob数据是否为有效图片
  73. const validateImageBlob = (blob: Blob): boolean => {
  74. // 允许的图片类型,包括通用的octet-stream类型(OSS服务器常返回此类型)
  75. const validTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp', 'image/bmp', 'application/octet-stream'];
  76. // 基本验证:类型检查和大小检查
  77. const hasValidType = validTypes.includes(blob.type) || blob.type === '' || blob.type === 'application/octet-stream';
  78. const hasValidSize = blob.size > 0;
  79. return hasValidType && hasValidSize;
  80. };
  81. // 通过认证请求获取图片数据并转换为blob URL
  82. const getImageBlobUrl = async (imageUrl: string): Promise<string> => {
  83. try {
  84. // 检查是否已有缓存的blob URL
  85. const cachedUrl = urlCache.value.get(imageUrl);
  86. if (cachedUrl) {
  87. return cachedUrl;
  88. }
  89. // 通过axios请求图片数据,这样会自动带上token和clientId
  90. const response = await request({
  91. url: imageUrl,
  92. method: 'get',
  93. responseType: 'blob',
  94. timeout: 300000 // 300秒超时
  95. });
  96. // 由于axios响应拦截器对blob类型响应直接返回blob数据
  97. // 这里response直接就是blob对象,而不是包含data属性的响应对象
  98. const blob = response;
  99. // 验证响应数据
  100. if (!blob) {
  101. throw new Error('响应数据为空');
  102. }
  103. if (!(blob instanceof Blob)) {
  104. // 如果不是Blob,尝试获取实际内容用于调试
  105. let actualContent = 'Unknown';
  106. try {
  107. if (typeof blob === 'string') {
  108. actualContent = (blob as string).substring(0, 200);
  109. } else if (blob && typeof blob.toString === 'function') {
  110. actualContent = blob.toString().substring(0, 200);
  111. }
  112. } catch (e) {
  113. actualContent = 'Cannot convert to string';
  114. }
  115. throw new Error(`响应数据不是有效的Blob对象。实际类型: ${typeof blob}, 内容: ${actualContent}`);
  116. }
  117. // 检查blob内容类型
  118. // const contentType = blob.type;
  119. // 如果Content-Type明确表明这不是图片数据且不是通用二进制格式,进行检查
  120. // if (
  121. // contentType &&
  122. // !contentType.toLowerCase().startsWith('image/') &&
  123. // !contentType.toLowerCase().includes('octet-stream') &&
  124. // (contentType.toLowerCase().includes('text/') || contentType.toLowerCase().includes('html'))
  125. // ) {
  126. // const text = await blob.text();
  127. // const preview = text.substring(0, 300);
  128. // throw new Error(`响应Content-Type不是图片格式: ${contentType}。响应内容预览: ${preview}`);
  129. // }
  130. // 验证是否为有效图片
  131. // if (!validateImageBlob(blob)) {
  132. // throw new Error(`无效的图片格式: ${blob.type}, 大小: ${blob.size}`);
  133. // }
  134. // 创建blob URL
  135. const blobUrl = URL.createObjectURL(blob);
  136. // 缓存blob URL
  137. // urlCache.value.set(imageUrl, blobUrl);
  138. // blobUrls.value.push(blobUrl);
  139. return blobUrl;
  140. } catch (error) {
  141. return '';
  142. }
  143. };
  144. // 通过ossid获取真实URL
  145. const getUrlByOssId = async (ossId: string): Promise<string> => {
  146. try {
  147. loading.value = true;
  148. // const response = await listByIds(ossId);
  149. // // RuoYi框架的响应格式:{code, msg, data},实际数据在data字段中
  150. // // 正确提取数据,考虑可能的响应格式
  151. // const ossList: OssVO[] = (response as any)?.data || response;
  152. // if (!ossList || ossList.length === 0) {
  153. // throw new Error(`OSS ID ${ossId} 在数据库中不存在或已被删除。可能原因:1) OSS ID不正确 2) 文件已被删除 3) 权限不足`);
  154. // }
  155. // const ossFile = ossList[0];
  156. // // 额外验证ossFile对象的完整性
  157. // if (!ossFile || typeof ossFile !== 'object') {
  158. // throw new Error(`OSS数据格式异常:期望对象,实际获得 ${typeof ossFile}`);
  159. // }
  160. // if (!ossFile.url) {
  161. // throw new Error(`OSS文件缺少url属性。完整数据:${JSON.stringify(ossFile)}`);
  162. // }
  163. // const imageUrl = ossFile.url;
  164. // // 检查URL是否有效
  165. // if (!imageUrl || typeof imageUrl !== 'string') {
  166. // throw new Error(`OSS接口返回的URL无效。URL: ${imageUrl}, 类型: ${typeof imageUrl}`);
  167. // }
  168. // // 如果URL不是以/开头的下载路径,可能是直接的OSS URL
  169. // if (!imageUrl.startsWith('/resource/oss/download/')) {
  170. // // 对于直接的OSS URL,我们仍然通过认证请求获取
  171. // }
  172. // 获取认证的blob URL
  173. return await getImageBlobUrl('/resource/oss/download/' + ossId);
  174. } catch (error) {
  175. return '';
  176. } finally {
  177. loading.value = false;
  178. }
  179. };
  180. // 处理多个ossid的情况
  181. const getUrlsByOssIds = async (ossIds: string[]): Promise<string[]> => {
  182. const urls: string[] = [];
  183. for (const ossId of ossIds) {
  184. if (ossId.trim()) {
  185. const url = await getUrlByOssId(ossId.trim());
  186. if (url) {
  187. urls.push(url);
  188. }
  189. }
  190. }
  191. return urls;
  192. };
  193. // 处理普通URL模式(非ossId)
  194. const getUrlsByDirectUrls = async (urls: string[]): Promise<string[]> => {
  195. const blobUrls: string[] = [];
  196. for (const url of urls) {
  197. if (!url.trim()) {
  198. continue;
  199. }
  200. const u = url.trim();
  201. // 如果已经是 blob / data URL,直接使用
  202. if (u.startsWith('blob:') || u.startsWith('data:')) {
  203. blobUrls.push(u);
  204. continue;
  205. }
  206. // 如果是本地静态资源(例如 Vite 打包的 /src 或 assets 路径),或者是绝对路径但不是受保护的下载接口,
  207. // 直接使用 URL,不经由带认证的请求去获取(避免对静态资源发带 token 的 XHR 导致失败)
  208. const isHttp = /^https?:\/\//i.test(u);
  209. const isProtectedDownload = u.startsWith('/resource/oss/download/');
  210. const isLocalAsset = u.startsWith('/src') || u.startsWith('/assets') || (u.startsWith('/') && !isProtectedDownload && !isHttp);
  211. if (isLocalAsset || isHttp) {
  212. // 对于 http(s) 链接直接使用原始 URL(多数第三方/公开图片可直接加载)
  213. blobUrls.push(u);
  214. continue;
  215. }
  216. // 对于受保护的资源或需认证获取的场景,使用认证请求获取 blob 并创建 blob URL
  217. const blobUrl = await getImageBlobUrl(u);
  218. if (blobUrl) {
  219. blobUrls.push(blobUrl);
  220. }
  221. }
  222. return blobUrls;
  223. };
  224. const realSrc = ref('');
  225. const realSrcList = ref<string[]>([]);
  226. // 处理src变化
  227. watch(
  228. () => props.src,
  229. async (newSrc) => {
  230. // 清理之前的blob URLs
  231. cleanupBlobUrls();
  232. if (!newSrc) {
  233. realSrc.value = '';
  234. realSrcList.value = [];
  235. return;
  236. }
  237. loading.value = true;
  238. try {
  239. if (props.isOssId) {
  240. // console.log('ossid模式');
  241. // console.log(newSrc);
  242. // ossid模式:通过接口获取真实URL,然后转换为认证的blob URL
  243. const ossIds = newSrc.split(',').filter((id) => id.trim());
  244. const urls = await getUrlsByOssIds(ossIds);
  245. if (urls.length > 0) {
  246. realSrc.value = urls[0];
  247. realSrcList.value = urls;
  248. } else {
  249. realSrc.value = '';
  250. realSrcList.value = [];
  251. }
  252. } else {
  253. // 普通模式:直接使用src作为URL,但仍需要通过认证请求转换为blob URL
  254. const urls = newSrc.split(',').filter((url) => url.trim());
  255. const blobUrls = await getUrlsByDirectUrls(urls);
  256. if (blobUrls.length > 0) {
  257. realSrc.value = blobUrls[0];
  258. realSrcList.value = blobUrls;
  259. } else {
  260. realSrc.value = '';
  261. realSrcList.value = [];
  262. }
  263. }
  264. } catch (error) {
  265. realSrc.value = '';
  266. realSrcList.value = [];
  267. } finally {
  268. loading.value = false;
  269. }
  270. },
  271. { immediate: true }
  272. );
  273. const realWidth = computed(() => (typeof props.width == 'string' ? props.width : `${props.width}px`));
  274. const realHeight = computed(() => (typeof props.height == 'string' ? props.height : `${props.height}px`));
  275. </script>
  276. <style lang="scss" scoped>
  277. .el-image {
  278. border-radius: 5px;
  279. background-color: #ebeef5;
  280. box-shadow: 0 0 5px 1px #ccc;
  281. :deep(.el-image__inner) {
  282. transition: all 0.3s;
  283. cursor: pointer;
  284. &:hover {
  285. transform: scale(1.2);
  286. }
  287. }
  288. :deep(.image-slot) {
  289. display: flex;
  290. flex-direction: column;
  291. justify-content: center;
  292. align-items: center;
  293. width: 100%;
  294. height: 100%;
  295. color: #909399;
  296. font-size: 30px;
  297. .error-text {
  298. font-size: 12px;
  299. margin-top: 8px;
  300. text-align: center;
  301. }
  302. }
  303. }
  304. </style>