|
|
@@ -35,18 +35,32 @@
|
|
|
</div>
|
|
|
|
|
|
<el-dialog v-model="dialogVisible" title="预览" width="800px" append-to-body>
|
|
|
- <img :src="dialogImageUrl" style="display: block; max-width: 100%; margin: 0 auto" />
|
|
|
+ <div v-loading="previewLoading" style="text-align: center; min-height: 200px">
|
|
|
+ <img
|
|
|
+ v-if="dialogImageUrl"
|
|
|
+ :src="dialogImageUrl"
|
|
|
+ style="display: block; max-width: 100%; margin: 0 auto"
|
|
|
+ @error="handlePreviewError"
|
|
|
+ @load="handlePreviewLoad"
|
|
|
+ />
|
|
|
+ <div v-if="previewError" class="preview-error">
|
|
|
+ <el-icon size="48"><picture-filled /></el-icon>
|
|
|
+ <div class="error-text">图片预览失败</div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
</el-dialog>
|
|
|
</div>
|
|
|
</template>
|
|
|
|
|
|
<script setup lang="ts">
|
|
|
-import { ref, computed, watch, nextTick, getCurrentInstance, type ComponentInternalInstance } from 'vue';
|
|
|
+import { ref, computed, watch, nextTick, getCurrentInstance, onBeforeUnmount, type ComponentInternalInstance } from 'vue';
|
|
|
import { listByIds, delOss } from '@/api/system/oss';
|
|
|
import { OssVO } from '@/api/system/oss/types';
|
|
|
import { propTypes } from '@/utils/propTypes';
|
|
|
import { globalHeaders } from '@/utils/request';
|
|
|
import { compressAccurately } from 'image-conversion';
|
|
|
+import { PictureFilled, ZoomIn, Delete } from '@element-plus/icons-vue';
|
|
|
+import request from '@/utils/request';
|
|
|
|
|
|
const props = defineProps({
|
|
|
modelValue: {
|
|
|
@@ -80,8 +94,18 @@ const uploadList = ref<any[]>([]);
|
|
|
const dialogImageUrl = ref('');
|
|
|
const dialogVisible = ref(false);
|
|
|
|
|
|
-// 使用环境变量,添加类型断言
|
|
|
-const baseUrl = (import.meta as any).env?.VITE_APP_BASE_API || '';
|
|
|
+// 预览相关状态
|
|
|
+const previewLoading = ref(false);
|
|
|
+const previewError = ref(false);
|
|
|
+const previewUrlCache = ref<Map<string, string>>(new Map());
|
|
|
+const previewBlobUrls = ref<string[]>([]);
|
|
|
+
|
|
|
+// 异步加载相关状态
|
|
|
+const loadingStates = ref<Map<string, boolean>>(new Map());
|
|
|
+const loadErrorStates = ref<Map<string, boolean>>(new Map());
|
|
|
+
|
|
|
+// 使用环境变量
|
|
|
+const baseUrl = '/dev-api';
|
|
|
const uploadImgUrl = ref(baseUrl + '/resource/oss/upload'); // 上传的图片服务器地址
|
|
|
const headers = ref(globalHeaders());
|
|
|
|
|
|
@@ -96,6 +120,215 @@ const isInternalUpdate = ref(false);
|
|
|
// 监听 fileType 变化,更新 fileAccept
|
|
|
const fileAccept = computed(() => props.fileType.map((type) => `.${type}`).join(','));
|
|
|
|
|
|
+// 清理预览blob URL,防止内存泄漏
|
|
|
+const cleanupPreviewBlobUrls = () => {
|
|
|
+ previewBlobUrls.value.forEach((url) => {
|
|
|
+ URL.revokeObjectURL(url);
|
|
|
+ });
|
|
|
+ previewBlobUrls.value = [];
|
|
|
+ previewUrlCache.value.clear();
|
|
|
+};
|
|
|
+
|
|
|
+// 组件卸载时清理blob URLs
|
|
|
+onBeforeUnmount(() => {
|
|
|
+ cleanupPreviewBlobUrls();
|
|
|
+});
|
|
|
+
|
|
|
+// 验证blob数据是否为有效图片
|
|
|
+const validateImageBlob = (blob: Blob): boolean => {
|
|
|
+ // 允许的图片类型,包括通用的octet-stream类型(OSS服务器常返回此类型)
|
|
|
+ const validTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp', 'image/bmp', 'application/octet-stream'];
|
|
|
+
|
|
|
+ // 基本验证:类型检查和大小检查
|
|
|
+ const hasValidType = validTypes.includes(blob.type) || blob.type === '' || blob.type === 'application/octet-stream';
|
|
|
+ const hasValidSize = blob.size > 0;
|
|
|
+
|
|
|
+ return hasValidType && hasValidSize;
|
|
|
+};
|
|
|
+
|
|
|
+// 通过认证请求获取图片数据并转换为blob URL
|
|
|
+const getPreviewImageBlobUrl = async (imageUrl: string): Promise<string> => {
|
|
|
+ try {
|
|
|
+ // 检查是否已有缓存的blob URL
|
|
|
+ const cachedUrl = previewUrlCache.value.get(imageUrl);
|
|
|
+ if (cachedUrl) {
|
|
|
+ return cachedUrl;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 通过request请求图片数据,这样会自动带上token和clientId
|
|
|
+ const response = await request({
|
|
|
+ url: imageUrl,
|
|
|
+ method: 'get',
|
|
|
+ responseType: 'blob',
|
|
|
+ timeout: 30000 // 30秒超时
|
|
|
+ });
|
|
|
+
|
|
|
+ // 由于request响应拦截器对blob类型响应直接返回blob数据
|
|
|
+ // 这里response直接就是blob对象,而不是包含data属性的响应对象
|
|
|
+ const blob = response;
|
|
|
+
|
|
|
+ // 验证响应数据
|
|
|
+ if (!blob) {
|
|
|
+ throw new Error('响应数据为空');
|
|
|
+ }
|
|
|
+
|
|
|
+ if (!(blob instanceof Blob)) {
|
|
|
+ // 如果不是Blob,尝试获取实际内容用于调试
|
|
|
+ let actualContent = 'Unknown';
|
|
|
+ try {
|
|
|
+ if (typeof blob === 'string') {
|
|
|
+ actualContent = (blob as string).substring(0, 200);
|
|
|
+ } else if (blob && typeof blob.toString === 'function') {
|
|
|
+ actualContent = blob.toString().substring(0, 200);
|
|
|
+ }
|
|
|
+ } catch (e) {
|
|
|
+ actualContent = 'Cannot convert to string';
|
|
|
+ }
|
|
|
+
|
|
|
+ throw new Error(`响应数据不是有效的Blob对象。实际类型: ${typeof blob}, 内容: ${actualContent}`);
|
|
|
+ }
|
|
|
+
|
|
|
+ // 检查blob内容类型
|
|
|
+ const contentType = blob.type;
|
|
|
+
|
|
|
+ // 如果Content-Type明确表明这不是图片数据且不是通用二进制格式,进行检查
|
|
|
+ if (
|
|
|
+ contentType &&
|
|
|
+ !contentType.toLowerCase().startsWith('image/') &&
|
|
|
+ !contentType.toLowerCase().includes('octet-stream') &&
|
|
|
+ (contentType.toLowerCase().includes('text/') || contentType.toLowerCase().includes('html'))
|
|
|
+ ) {
|
|
|
+ const text = await blob.text();
|
|
|
+ const preview = text.substring(0, 300);
|
|
|
+ throw new Error(`响应Content-Type不是图片格式: ${contentType}。响应内容预览: ${preview}`);
|
|
|
+ }
|
|
|
+
|
|
|
+ // 验证是否为有效图片
|
|
|
+ if (!validateImageBlob(blob)) {
|
|
|
+ throw new Error(`无效的图片格式: ${blob.type}, 大小: ${blob.size}`);
|
|
|
+ }
|
|
|
+
|
|
|
+ // 创建blob URL
|
|
|
+ const blobUrl = URL.createObjectURL(blob);
|
|
|
+
|
|
|
+ // 缓存blob URL
|
|
|
+ previewUrlCache.value.set(imageUrl, blobUrl);
|
|
|
+ previewBlobUrls.value.push(blobUrl);
|
|
|
+
|
|
|
+ return blobUrl;
|
|
|
+ } catch (error) {
|
|
|
+ console.error('获取预览图片失败:', error);
|
|
|
+ return '';
|
|
|
+ }
|
|
|
+};
|
|
|
+
|
|
|
+// 通过ossid获取预览URL
|
|
|
+const getPreviewUrlByOssId = async (ossId: string): Promise<string> => {
|
|
|
+ try {
|
|
|
+ const response = await listByIds(ossId);
|
|
|
+ // RuoYi框架的响应格式:{code, msg, data},实际数据在data字段中
|
|
|
+ const ossList: OssVO[] = (response as any)?.data || response;
|
|
|
+
|
|
|
+ if (!ossList || ossList.length === 0) {
|
|
|
+ throw new Error(`OSS ID ${ossId} 在数据库中不存在或已被删除`);
|
|
|
+ }
|
|
|
+
|
|
|
+ const ossFile = ossList[0];
|
|
|
+
|
|
|
+ if (!ossFile || !ossFile.url) {
|
|
|
+ throw new Error(`OSS文件缺少url属性`);
|
|
|
+ }
|
|
|
+
|
|
|
+ const imageUrl = ossFile.url;
|
|
|
+
|
|
|
+ // 检查URL是否有效
|
|
|
+ if (!imageUrl || typeof imageUrl !== 'string') {
|
|
|
+ throw new Error(`OSS接口返回的URL无效`);
|
|
|
+ }
|
|
|
+
|
|
|
+ // 获取认证的blob URL
|
|
|
+ return await getPreviewImageBlobUrl(imageUrl);
|
|
|
+ } catch (error) {
|
|
|
+ console.error('通过ossId获取预览图片失败:', error);
|
|
|
+ throw error;
|
|
|
+ }
|
|
|
+};
|
|
|
+
|
|
|
+// 异步加载单个文件的图片
|
|
|
+const loadFileImageAsync = async (item: any, index: number): Promise<void> => {
|
|
|
+ const fileKey = item.ossId || item.url || `file_${index}`;
|
|
|
+
|
|
|
+ // 设置加载状态
|
|
|
+ loadingStates.value.set(fileKey, true);
|
|
|
+ loadErrorStates.value.set(fileKey, false);
|
|
|
+
|
|
|
+ try {
|
|
|
+ let blobUrl = '';
|
|
|
+
|
|
|
+ if (typeof item === 'string') {
|
|
|
+ // 直接URL情况
|
|
|
+ blobUrl = await getPreviewImageBlobUrl(item);
|
|
|
+ } else if (item.url) {
|
|
|
+ // OSS对象情况
|
|
|
+ blobUrl = await getPreviewImageBlobUrl(item.url);
|
|
|
+ }
|
|
|
+
|
|
|
+ if (blobUrl) {
|
|
|
+ // 更新文件列表中的对应项
|
|
|
+ const fileIndex = fileList.value.findIndex((f) => f.ossId === item.ossId || f.url === item.url || f.originalUrl === item.originalUrl);
|
|
|
+
|
|
|
+ if (fileIndex !== -1) {
|
|
|
+ fileList.value[fileIndex].url = blobUrl;
|
|
|
+ fileList.value[fileIndex].isLoaded = true;
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ throw new Error('获取图片blob URL失败');
|
|
|
+ }
|
|
|
+ } catch (error) {
|
|
|
+ console.error('异步加载图片失败:', error);
|
|
|
+ loadErrorStates.value.set(fileKey, true);
|
|
|
+ } finally {
|
|
|
+ loadingStates.value.set(fileKey, false);
|
|
|
+ }
|
|
|
+};
|
|
|
+
|
|
|
+// 批量异步加载图片
|
|
|
+const loadImagesAsync = async (items: any[]): Promise<void> => {
|
|
|
+ // 先创建占位框,使用一个占位图片URL
|
|
|
+ const placeholderItems = items.map((item, index) => {
|
|
|
+ const name =
|
|
|
+ item.url && (item.url.startsWith('http://') || item.url.startsWith('https://')) ? `${item.url}_${index}` : item.ossId || `file_${index}`;
|
|
|
+
|
|
|
+ return {
|
|
|
+ name: name,
|
|
|
+ url: 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTAwIiBoZWlnaHQ9IjEwMCIgdmlld0JveD0iMCAwIDEwMCAxMDAiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxyZWN0IHdpZHRoPSIxMDAiIGhlaWdodD0iMTAwIiBmaWxsPSIjZjVmN2ZhIi8+CjxjaXJjbGUgY3g9IjUwIiBjeT0iNTAiIHI9IjE1IiBzdHJva2U9IiM5MDkzOTkiIHN0cm9rZS13aWR0aD0iMiIgZmlsbD0ibm9uZSIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtZGFzaGFycmF5PSI2LCAzIiBzdHJva2UtbGluZWpvaW49InJvdW5kIj4KCTxhbmltYXRlVHJhbnNmb3JtIGF0dHJpYnV0ZU5hbWU9InRyYW5zZm9ybSIgdHlwZT0icm90YXRlIiB2YWx1ZXM9IjAgNTAgNTA7MzYwIDUwIDUwIiBkdXI9IjFzIiByZXBlYXRDb3VudD0iaW5kZWZpbml0ZSIvPgo8L2NpcmNsZT4KPC9zdmc+', // 小圆圈浅灰色加载中占位图片
|
|
|
+ originalUrl: item.url,
|
|
|
+ ossId: item.ossId,
|
|
|
+ isLoaded: false, // 标记为未加载
|
|
|
+ isPlaceholder: true // 标记为占位符
|
|
|
+ };
|
|
|
+ });
|
|
|
+
|
|
|
+ // 立即更新文件列表显示占位框
|
|
|
+ fileList.value = placeholderItems;
|
|
|
+
|
|
|
+ // 异步加载所有图片
|
|
|
+ const loadPromises = items.map((item, index) => loadFileImageAsync(item, index));
|
|
|
+
|
|
|
+ // 等待所有图片加载完成(使用Promise.all替代allSettled以兼容旧版本)
|
|
|
+ await Promise.all(loadPromises);
|
|
|
+};
|
|
|
+
|
|
|
+// 图片预览加载成功
|
|
|
+const handlePreviewLoad = () => {
|
|
|
+ previewError.value = false;
|
|
|
+};
|
|
|
+
|
|
|
+// 图片预览加载失败
|
|
|
+const handlePreviewError = () => {
|
|
|
+ previewError.value = true;
|
|
|
+};
|
|
|
+
|
|
|
watch(
|
|
|
() => props.modelValue,
|
|
|
async (val: string) => {
|
|
|
@@ -136,20 +369,26 @@ watch(
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
- // 然后将数组转为对象数组
|
|
|
- fileList.value = list.map((item, index) => {
|
|
|
- // 字符串回显处理 如果此处存的是url可直接回显 如果存的是id需要调用接口查出来
|
|
|
- let itemData;
|
|
|
- if (typeof item === 'string') {
|
|
|
- itemData = { name: item, url: item };
|
|
|
- } else {
|
|
|
- // 此处name使用url 防止删除出现重名,对于URL格式使用url_index的格式
|
|
|
- const name = item.url && (item.url.startsWith('http://') || item.url.startsWith('https://')) ? `${item.url}_${index}` : item.ossId;
|
|
|
- itemData = { name: name, url: item.url, ossId: item.ossId };
|
|
|
- }
|
|
|
- return itemData;
|
|
|
- });
|
|
|
- console.log('fileList', fileList.value);
|
|
|
+
|
|
|
+ // 使用异步加载方式处理文件列表
|
|
|
+ try {
|
|
|
+ await loadImagesAsync(list);
|
|
|
+ console.log('fileList with async loaded images', fileList.value);
|
|
|
+ } catch (error) {
|
|
|
+ console.error('异步加载文件列表失败:', error);
|
|
|
+ // 降级处理:使用原始URL
|
|
|
+ fileList.value = list.map((item, index) => {
|
|
|
+ let itemData;
|
|
|
+ if (typeof item === 'string') {
|
|
|
+ itemData = { name: item, url: item, originalUrl: item, isLoaded: true };
|
|
|
+ } else {
|
|
|
+ const name = item.url && (item.url.startsWith('http://') || item.url.startsWith('https://')) ? `${item.url}_${index}` : item.ossId;
|
|
|
+ itemData = { name: name, url: item.url, originalUrl: item.url, ossId: item.ossId, isLoaded: true };
|
|
|
+ }
|
|
|
+ return itemData;
|
|
|
+ });
|
|
|
+ console.log('fileList (fallback)', fileList.value);
|
|
|
+ }
|
|
|
} else {
|
|
|
fileList.value = [];
|
|
|
return [];
|
|
|
@@ -226,7 +465,16 @@ const handleDelete = (file: any): boolean => {
|
|
|
const findex = fileList.value.map((f) => f.name).indexOf(file.name);
|
|
|
if (findex > -1 && uploadList.value.length === number.value) {
|
|
|
const ossId = fileList.value[findex].ossId;
|
|
|
- delOss(ossId);
|
|
|
+ if (ossId) {
|
|
|
+ // delOss(ossId);
|
|
|
+ }
|
|
|
+
|
|
|
+ // 清理对应的blob URL
|
|
|
+ const fileToDelete = fileList.value[findex];
|
|
|
+ if (fileToDelete.url && fileToDelete.url.startsWith('blob:')) {
|
|
|
+ URL.revokeObjectURL(fileToDelete.url);
|
|
|
+ }
|
|
|
+
|
|
|
fileList.value.splice(findex, 1);
|
|
|
// 设置内部更新标志
|
|
|
isInternalUpdate.value = true;
|
|
|
@@ -264,10 +512,38 @@ const handleUploadError = () => {
|
|
|
};
|
|
|
|
|
|
// 预览
|
|
|
-const handlePictureCardPreview = (file: any) => {
|
|
|
- console.log('file', file);
|
|
|
- dialogImageUrl.value = file.url;
|
|
|
+const handlePictureCardPreview = async (file: any) => {
|
|
|
+ console.log('预览文件:', file);
|
|
|
+ previewLoading.value = true;
|
|
|
+ previewError.value = false;
|
|
|
dialogVisible.value = true;
|
|
|
+
|
|
|
+ try {
|
|
|
+ let previewUrl = '';
|
|
|
+
|
|
|
+ // 判断是否为ossId
|
|
|
+ if (file.ossId) {
|
|
|
+ // 通过ossId获取预览URL
|
|
|
+ previewUrl = await getPreviewUrlByOssId(file.ossId);
|
|
|
+ } else if (file.originalUrl) {
|
|
|
+ // 使用原始URL进行预览(避免重复转换)
|
|
|
+ previewUrl = await getPreviewImageBlobUrl(file.originalUrl);
|
|
|
+ } else if (file.url) {
|
|
|
+ // 直接URL预览
|
|
|
+ previewUrl = await getPreviewImageBlobUrl(file.url);
|
|
|
+ } else {
|
|
|
+ throw new Error('文件缺少必要的URL或ossId信息');
|
|
|
+ }
|
|
|
+
|
|
|
+ dialogImageUrl.value = previewUrl;
|
|
|
+ } catch (error) {
|
|
|
+ console.error('图片预览失败:', error);
|
|
|
+ previewError.value = true;
|
|
|
+ dialogImageUrl.value = '';
|
|
|
+ (proxy as any)?.$modal.msgError('图片预览失败');
|
|
|
+ } finally {
|
|
|
+ previewLoading.value = false;
|
|
|
+ }
|
|
|
};
|
|
|
|
|
|
// 对象转成指定字符串分隔
|
|
|
@@ -276,7 +552,7 @@ const listToString = (list: any[], separator?: string) => {
|
|
|
separator = separator || ',';
|
|
|
for (const i in list) {
|
|
|
if (undefined !== list[i].ossId && list[i].ossId.indexOf('blob:') !== 0) {
|
|
|
- // 始终返回URL而不是ossId
|
|
|
+ // 返回ossId用于数据存储
|
|
|
strs += list[i].ossId + separator;
|
|
|
}
|
|
|
}
|
|
|
@@ -289,4 +565,41 @@ const listToString = (list: any[], separator?: string) => {
|
|
|
:deep(.hide .el-upload--picture-card) {
|
|
|
display: none;
|
|
|
}
|
|
|
+
|
|
|
+// 预览错误状态样式
|
|
|
+.preview-error {
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ justify-content: center;
|
|
|
+ align-items: center;
|
|
|
+ color: #909399;
|
|
|
+ min-height: 200px;
|
|
|
+
|
|
|
+ .error-text {
|
|
|
+ font-size: 14px;
|
|
|
+ margin-top: 8px;
|
|
|
+ text-align: center;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// 异步加载优化样式
|
|
|
+:deep(.el-upload-list--picture-card .el-upload-list__item) {
|
|
|
+ // 占位图片加载时的样式
|
|
|
+ .el-upload-list__item-thumbnail {
|
|
|
+ transition: opacity 0.3s ease;
|
|
|
+
|
|
|
+ &.loading {
|
|
|
+ opacity: 0.6;
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+@keyframes loading-rotate {
|
|
|
+ 0% {
|
|
|
+ transform: rotate(0deg);
|
|
|
+ }
|
|
|
+ 100% {
|
|
|
+ transform: rotate(360deg);
|
|
|
+ }
|
|
|
+}
|
|
|
</style>
|