提交代码
This commit is contained in:
1 parent
0ed820d7ce
commit
4a2761cb0d
8 files changed
+1481
-159
No files matched your search
+50
-11
@@ -85,9 +85,12 @@ public class SysOssServiceImpl implements ISysOssService, OssService {
|
||||
SysOssVo vo = ossService.getById(id);
|
||||
if (ObjectUtil.isNotNull(vo)) {
|
||||
try {
|
||||
list.add(this.matchingUrl(vo));
|
||||
// 使用安全文件访问URL替代直接OSS URL
|
||||
String secureUrl = this.getSecureFileUrl(id);
|
||||
vo.setUrl(secureUrl);
|
||||
list.add(vo);
|
||||
} catch (Exception ignored) {
|
||||
// 如果oss异常无法连接则将数据直接返回
|
||||
// 如果安全访问失败则将数据直接返回
|
||||
list.add(vo);
|
||||
}
|
||||
}
|
||||
@@ -177,9 +180,26 @@ public class SysOssServiceImpl implements ISysOssService, OssService {
|
||||
throw new ServiceException("文件数据不存在!");
|
||||
}
|
||||
FileUtils.setAttachmentResponseHeader(response, sysOss.getOriginalName());
|
||||
response.setContentType(MediaType.APPLICATION_OCTET_STREAM_VALUE + "; charset=UTF-8");
|
||||
OssClient storage = OssFactory.instance(sysOss.getService());
|
||||
storage.download(sysOss.getFileName(), response.getOutputStream(), response::setContentLengthLong);
|
||||
response.setContentType(MediaType.APPLICATION_OCTET_STREAM_VALUE);
|
||||
|
||||
// 添加缓存控制头,防止浏览器缓存干扰
|
||||
response.setHeader("Cache-Control", "no-cache, no-store, must-revalidate");
|
||||
response.setHeader("Pragma", "no-cache");
|
||||
response.setHeader("Expires", "0");
|
||||
|
||||
try {
|
||||
OssClient storage = OssFactory.instance(sysOss.getService());
|
||||
storage.download(sysOss.getFileName(), response.getOutputStream(), contentLength -> {
|
||||
if (contentLength != null && contentLength > 0) {
|
||||
response.setContentLengthLong(contentLength);
|
||||
}
|
||||
});
|
||||
|
||||
// 确保输出流刷新
|
||||
response.getOutputStream().flush();
|
||||
} catch (Exception e) {
|
||||
throw new ServiceException("文件下载失败: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -298,7 +318,7 @@ public class SysOssServiceImpl implements ISysOssService, OssService {
|
||||
}
|
||||
|
||||
// 返回后端代理URL,而不是直接返回OSS URL
|
||||
return "/system/oss/download/" + ossId;
|
||||
return "/resource/oss/download/" + ossId;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -329,13 +349,32 @@ public class SysOssServiceImpl implements ISysOssService, OssService {
|
||||
// 日志记录失败不影响主流程
|
||||
}
|
||||
|
||||
// 设置响应头
|
||||
// 🔧 修复:设置正确的响应头,移除charset避免二进制文件问题
|
||||
FileUtils.setAttachmentResponseHeader(response, sysOss.getOriginalName());
|
||||
response.setContentType(MediaType.APPLICATION_OCTET_STREAM_VALUE + "; charset=UTF-8");
|
||||
response.setContentType(MediaType.APPLICATION_OCTET_STREAM_VALUE);
|
||||
|
||||
// 通过OSS客户端下载并直接输出到响应流
|
||||
OssClient storage = OssFactory.instance(sysOss.getService());
|
||||
storage.download(sysOss.getFileName(), response.getOutputStream(), response::setContentLengthLong);
|
||||
// 🔧 修复:禁用缓存,确保文件下载正确处理
|
||||
response.setHeader("Cache-Control", "no-cache, no-store, must-revalidate");
|
||||
response.setHeader("Pragma", "no-cache");
|
||||
response.setHeader("Expires", "0");
|
||||
|
||||
try {
|
||||
// 通过OSS客户端下载并直接输出到响应流
|
||||
OssClient storage = OssFactory.instance(sysOss.getService());
|
||||
storage.download(sysOss.getFileName(), response.getOutputStream(), contentLength -> {
|
||||
// 🔧 修复:确保Content-Length正确设置
|
||||
if (contentLength != null && contentLength > 0) {
|
||||
response.setContentLengthLong(contentLength);
|
||||
}
|
||||
});
|
||||
|
||||
// 🔧 修复:确保输出流刷新
|
||||
response.getOutputStream().flush();
|
||||
|
||||
} catch (Exception e) {
|
||||
// 🔧 修复:更详细的错误日志
|
||||
throw new ServiceException("文件下载失败: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -169,4 +169,153 @@ const handleAdd = () => {
|
||||
此修复确保了角色管理页面的新增功能正常工作,同时保持了对系统内置角色的保护机制。修复后的代码更加健壮,能够正确处理各种边界情况。
|
||||
|
||||
**修复状态**: ✅ 完全成功
|
||||
**验证时间**: 2025-01-27 15:35:00
|
||||
**验证时间**: 2025-01-27 15:35:00
|
||||
|
||||
**调整时间**: 2025-01-27 15:30:00
|
||||
**调整人员**: wuzilong
|
||||
**调整文件**: `web/src/components/ImageUpload/index.vue`
|
||||
|
||||
## 📋 调整概述
|
||||
|
||||
优化图片上传组件的图片预览加载方式,从同步加载改为异步加载,提升用户体验。
|
||||
|
||||
## 🔧 主要改动
|
||||
|
||||
### 1. 异步加载状态管理
|
||||
- 新增 `loadingStates` 和 `loadErrorStates` 状态管理
|
||||
- 添加 `getLoadErrorState` 方法获取文件加载错误状态
|
||||
|
||||
### 2. 异步加载核心方法
|
||||
- **`loadFileImageAsync`**: 异步加载单个文件的图片
|
||||
- **`loadImagesAsync`**: 批量异步加载图片,先显示占位框再加载内容
|
||||
|
||||
### 3. 模板优化
|
||||
- 添加自定义文件模板 `#file`,支持加载状态显示
|
||||
- 实现三种状态:加载中、加载失败、正常显示
|
||||
|
||||
### 4. 样式优化
|
||||
- 添加加载占位框样式(旋转动画)
|
||||
- 添加错误状态样式
|
||||
- 优化图片显示样式
|
||||
|
||||
## 🎯 功能特点
|
||||
|
||||
### 异步加载流程
|
||||
1. **立即显示占位框**: 6个图片立即显示6个占位框
|
||||
2. **并行异步加载**: 所有图片同时开始加载blob URL
|
||||
3. **状态反馈**: 加载中显示旋转动画,失败显示错误图标
|
||||
4. **渐进式显示**: 每个图片加载完成后立即显示
|
||||
|
||||
### 用户体验提升
|
||||
- ✅ **快速响应**: 界面立即显示,无需等待所有图片加载
|
||||
- ✅ **视觉反馈**: 清晰的加载状态指示
|
||||
- ✅ **错误处理**: 加载失败时显示友好的错误提示
|
||||
- ✅ **性能优化**: 并行加载,减少总体等待时间
|
||||
|
||||
## 🔄 兼容性处理
|
||||
|
||||
- 使用 `Promise.all` 替代 `Promise.allSettled` 以兼容旧版本
|
||||
- 保留原有的降级处理逻辑
|
||||
- 保持与现有API的完全兼容
|
||||
|
||||
## 📊 技术实现
|
||||
|
||||
### 核心算法
|
||||
```typescript
|
||||
// 1. 创建占位框
|
||||
const placeholderItems = items.map(item => ({
|
||||
name: item.name,
|
||||
url: '', // 空URL显示占位框
|
||||
isLoaded: false
|
||||
}));
|
||||
|
||||
// 2. 立即更新UI
|
||||
fileList.value = placeholderItems;
|
||||
|
||||
// 3. 并行异步加载
|
||||
await Promise.all(items.map(loadFileImageAsync));
|
||||
```
|
||||
|
||||
### 状态管理
|
||||
- **加载状态**: `loadingStates` Map管理每个文件的加载状态
|
||||
- **错误状态**: `loadErrorStates` Map管理每个文件的错误状态
|
||||
- **缓存机制**: `previewUrlCache` 避免重复加载相同图片
|
||||
|
||||
## 🎨 视觉效果
|
||||
|
||||
- **加载中**: 蓝色旋转动画 + "加载中..."文字
|
||||
- **加载失败**: 红色感叹号 + "加载失败"文字
|
||||
- **加载成功**: 正常图片显示
|
||||
|
||||
## ✅ 测试验证
|
||||
|
||||
- [x] 多图片异步加载测试
|
||||
- [x] 加载失败状态测试
|
||||
- [x] 缓存机制测试
|
||||
- [x] 内存泄漏防护测试
|
||||
- [x] 兼容性测试
|
||||
- [x] 操作按钮功能测试
|
||||
|
||||
## 🔧 修复记录
|
||||
|
||||
### 2025-01-27 16:00:00 - 修复操作按钮显示问题
|
||||
|
||||
**问题**: 自定义文件模板覆盖了Element Plus默认的删除和预览按钮
|
||||
|
||||
**根本原因**: 使用 `#file` 插槽完全覆盖了Element Plus的默认文件显示逻辑
|
||||
|
||||
**解决方案**:
|
||||
1. **移除自定义模板**: 删除 `#file` 插槽,让Element Plus使用默认的文件显示逻辑
|
||||
2. **使用占位图片**: 在异步加载时使用SVG占位图片,确保Element Plus能正常显示
|
||||
3. **保留默认操作按钮**: Element Plus的 `picture-card` 模式会自动显示删除和预览按钮
|
||||
|
||||
**修复内容**:
|
||||
- 移除自定义文件模板 `#file`
|
||||
- 使用SVG占位图片替代空URL
|
||||
- 简化CSS样式,移除不必要的自定义样式
|
||||
- 移除不再需要的 `getLoadErrorState` 方法
|
||||
|
||||
**验证结果**: ✅ 删除和预览按钮功能完全恢复,异步加载功能正常工作
|
||||
|
||||
### 2025-01-27 16:30:00 - 优化占位图片为加载中效果
|
||||
|
||||
**改进内容**:
|
||||
- 将静态占位图片改为带有旋转动画的加载中效果
|
||||
- 使用简洁的蓝色旋转圆圈动画,符合Element Plus设计风格
|
||||
|
||||
**视觉效果**:
|
||||
- **背景**: 浅灰色背景 (#f5f7fa)
|
||||
- **动画**: 浅灰色旋转虚线圆圈,1秒循环
|
||||
- **尺寸**: 小圆圈半径(15px),细线条(2px)
|
||||
- **整体**: 简洁现代的加载状态显示
|
||||
|
||||
**技术实现**:
|
||||
- 使用SVG内联动画,无需额外CSS
|
||||
- Base64编码,减少HTTP请求
|
||||
- 完全自包含,无外部依赖
|
||||
- 移除文字,保持界面简洁
|
||||
|
||||
### 2025-01-27 17:00:00 - 进一步优化加载动画样式
|
||||
|
||||
**优化内容**:
|
||||
- 将圆圈半径从25px缩小到15px,更加精致
|
||||
- 将颜色从蓝色改为浅灰色(#909399),更加低调
|
||||
- 将线条宽度从3px减少到2px,更加细腻
|
||||
- 调整虚线比例为6px实线+3px虚线,更加协调
|
||||
|
||||
**视觉效果**:
|
||||
- **尺寸**: 15px半径的小圆圈,更加精致
|
||||
- **颜色**: 浅灰色(#909399),低调不抢眼
|
||||
- **线条**: 2px细线条,更加细腻
|
||||
- **整体**: 更加简洁低调的加载效果
|
||||
|
||||
## 📝 后续优化建议
|
||||
|
||||
1. **预加载机制**: 可考虑添加图片预加载功能
|
||||
2. **懒加载**: 对于大量图片可考虑懒加载
|
||||
3. **压缩优化**: 可添加图片压缩功能
|
||||
4. **重试机制**: 加载失败时自动重试
|
||||
|
||||
---
|
||||
|
||||
**🎯 目标达成**: 成功实现图片异步加载,显著提升用户体验!
|
||||
@@ -1,114 +1,240 @@
|
||||
# 代码调整记录 - 2025-08-02
|
||||
# 代码调整记录 - 2025-08-02 - wuzilong
|
||||
|
||||
## ImagePreview 组件修复和功能增强
|
||||
## 2025-08-02 16:30:00 - 修改ImageUpload组件getPreviewImageBlobUrl方法使用request
|
||||
|
||||
### 修改文件
|
||||
- `web/src/components/ImagePreview/index.vue`
|
||||
- `docs/开发文档/ImagePreview组件使用说明.md` (新增)
|
||||
### 修改目标
|
||||
修改 `web/src/components/ImageUpload/index.vue` 中的 `getPreviewImageBlobUrl` 方法,参考 ImagePreview 组件中的 `getImageBlobUrl` 方法,使用统一的 `request` 方法调用接口。
|
||||
|
||||
### 主要变更
|
||||
1. **导入更改**:
|
||||
- 删除了 `import axios from 'axios'`
|
||||
- 添加了 `import request from '@/utils/request'`
|
||||
- 删除了不再需要的 `getToken` 函数
|
||||
|
||||
2. **请求方式更改**:
|
||||
- 将 `axios` 调用替换为 `request` 调用
|
||||
- 移除了手动设置的 `Authorization` 和 `clientid` headers(`request` 方法会自动处理)
|
||||
- 保持了相同的请求参数(URL、method、responseType、timeout)
|
||||
|
||||
3. **响应处理更改**:
|
||||
- 根据 ImagePreview 组件的实现,调整了响应数据处理方式
|
||||
- `request` 方法对 blob 类型响应直接返回 blob 数据,而不是包含 `data` 属性的响应对象
|
||||
- 增强了错误处理,提供更详细的调试信息
|
||||
|
||||
4. **错误处理优化**:
|
||||
- 将 `throw error` 改为 `return ''`,与 ImagePreview 组件保持一致
|
||||
- 增加了更详细的错误信息用于调试
|
||||
|
||||
### 技术价值
|
||||
- 统一了图片组件的网络请求方式,使用项目标准的 `request` 方法
|
||||
- 确保了统一的认证处理(自动携带token和clientId)
|
||||
- 提高了代码一致性和可维护性
|
||||
- 改进了错误处理机制
|
||||
|
||||
### 文件修改
|
||||
- `web/src/components/ImageUpload/index.vue`:修改 getPreviewImageBlobUrl 方法实现
|
||||
|
||||
---
|
||||
|
||||
## 2025-08-02 18:30:00 - ImageUpload组件增强:支持认证图片回显和预览
|
||||
|
||||
### 问题描述
|
||||
1. **导入错误**:`Cannot find module '@/utils/propTypes'`
|
||||
2. **computed未定义**:`Cannot find name 'computed'`
|
||||
3. **功能需求**:需要支持通过ossid调用接口获取真实URL进行图片展示
|
||||
ImageUpload组件在显示历史图片数据时存在认证问题:
|
||||
1. **初始回显问题**:界面打开时,历史图片数据没有通过认证接口获取blob URL,导致图片无法正常显示
|
||||
2. **预览功能限制**:点击预览时直接使用原始URL,需要认证才能访问
|
||||
3. **内存管理缺失**:没有对blob URL进行内存管理,可能导致内存泄漏
|
||||
|
||||
### 解决方案
|
||||
|
||||
#### 1. 修复导入问题
|
||||
- 移除对 `@/utils/propTypes` 的依赖
|
||||
- 直接使用Vue 3的 `defineProps` 语法
|
||||
- 添加必要的Vue 3导入:`computed`, `ref`, `watch`
|
||||
|
||||
#### 2. 增强功能支持
|
||||
- **新增 `isOssId` 属性**:支持OSS ID模式
|
||||
- **URL缓存机制**:使用Map缓存已获取的URL,避免重复请求
|
||||
- **异步加载**:通过watch监听src变化,异步获取真实URL
|
||||
- **加载状态**:添加 `v-loading` 指令显示加载状态
|
||||
- **错误处理**:完善的错误处理和日志记录
|
||||
|
||||
#### 3. 核心功能实现
|
||||
|
||||
#### 1. 数据回显认证处理
|
||||
```typescript
|
||||
// 通过ossid获取真实URL
|
||||
const getUrlByOssId = async (ossId: string): Promise<string> => {
|
||||
// 检查缓存
|
||||
if (urlCache.value.has(ossId)) {
|
||||
return urlCache.value.get(ossId)!;
|
||||
}
|
||||
// 新增函数:处理单个文件的blob URL转换
|
||||
const processFileWithBlobUrl = async (item: any, index: number): Promise<any> => {
|
||||
// 通过认证接口获取blob URL
|
||||
const blobUrl = await getPreviewImageBlobUrl(item.url);
|
||||
return {
|
||||
name: name,
|
||||
url: blobUrl, // 用于显示的认证blob URL
|
||||
originalUrl: item.url, // 保存原始URL用于预览
|
||||
ossId: item.ossId
|
||||
};
|
||||
};
|
||||
|
||||
try {
|
||||
loading.value = true;
|
||||
const response = await listByIds(ossId);
|
||||
const ossList: OssVO[] = response.data;
|
||||
|
||||
if (ossList && ossList.length > 0) {
|
||||
const url = ossList[0].url;
|
||||
// 缓存结果
|
||||
urlCache.value.set(ossId, url);
|
||||
return url;
|
||||
// 修改watch监听器:异步处理所有文件
|
||||
const processedList = await Promise.all(
|
||||
list.map((item, index) => processFileWithBlobUrl(item, index))
|
||||
);
|
||||
fileList.value = processedList;
|
||||
```
|
||||
|
||||
#### 2. 认证图片获取函数
|
||||
```typescript
|
||||
// 通过认证请求获取图片数据并转换为blob URL
|
||||
const getPreviewImageBlobUrl = async (imageUrl: string): Promise<string> => {
|
||||
// 检查缓存
|
||||
const cachedUrl = previewUrlCache.value.get(imageUrl);
|
||||
if (cachedUrl) return cachedUrl;
|
||||
|
||||
// 认证请求
|
||||
const response = await axios({
|
||||
url: imageUrl,
|
||||
method: 'get',
|
||||
responseType: 'blob',
|
||||
headers: {
|
||||
'Authorization': 'Bearer ' + getToken(),
|
||||
'clientid': 'web'
|
||||
}
|
||||
return '';
|
||||
} catch (error) {
|
||||
console.error('获取OSS URL失败:', error);
|
||||
return '';
|
||||
} finally {
|
||||
loading.value = false;
|
||||
});
|
||||
|
||||
// 创建和缓存blob URL
|
||||
const blobUrl = URL.createObjectURL(response.data);
|
||||
previewUrlCache.value.set(imageUrl, blobUrl);
|
||||
return blobUrl;
|
||||
};
|
||||
```
|
||||
|
||||
#### 3. 内存管理优化
|
||||
```typescript
|
||||
// 组件卸载时清理blob URLs
|
||||
onBeforeUnmount(() => {
|
||||
cleanupPreviewBlobUrls();
|
||||
});
|
||||
|
||||
// 删除时清理对应的blob URL
|
||||
const handleDelete = (file: any): boolean => {
|
||||
const fileToDelete = fileList.value[findex];
|
||||
if (fileToDelete.url && fileToDelete.url.startsWith('blob:')) {
|
||||
URL.revokeObjectURL(fileToDelete.url);
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
#### 4. 使用方式
|
||||
#### 4. 预览功能增强
|
||||
- 优先使用 `originalUrl` 进行预览,避免重复转换
|
||||
- 增加加载状态和错误处理
|
||||
- 添加错误重试机制
|
||||
|
||||
**普通模式(直接使用URL)**:
|
||||
```vue
|
||||
<ImagePreview
|
||||
src="https://example.com/image1.jpg,https://example.com/image2.jpg"
|
||||
:width="200"
|
||||
:height="150"
|
||||
/>
|
||||
### 修改文件
|
||||
- `web/src/components/ImageUpload/index.vue` - 主要功能修改
|
||||
|
||||
### 测试验证
|
||||
- ✅ 界面打开时历史图片正常显示(通过认证接口)
|
||||
- ✅ 点击预览功能正常工作
|
||||
- ✅ 上传新图片功能正常
|
||||
- ✅ 删除图片功能正常,无内存泄漏
|
||||
- ✅ 错误处理和降级机制正常
|
||||
|
||||
### 技术要点
|
||||
1. **异步数据处理**:使用 `Promise.all` 并行处理多个文件的blob URL转换
|
||||
2. **缓存机制**:避免重复请求相同图片的认证数据
|
||||
3. **降级处理**:认证失败时使用原始URL,确保基本功能可用
|
||||
4. **内存安全**:完整的blob URL生命周期管理
|
||||
|
||||
---
|
||||
|
||||
## 2025-08-02 15:20:00 - ImagePreview组件修复OSS图片显示问题
|
||||
|
||||
### 问题描述
|
||||
ImagePreview组件在处理OSS图片时出现两个关键问题:
|
||||
1. **API响应数据处理错误**:错误地假设响应拦截器直接返回数组数据,实际应访问`response.data`
|
||||
2. **图片格式验证过于严格**:拒绝`application/octet-stream`类型,但OSS服务器常返回此通用二进制格式
|
||||
|
||||
### 错误信息
|
||||
```
|
||||
ImagePreview: 获取OSS URL失败
|
||||
{ossId: '1951584287279034370', error: TypeError: Cannot read properties of undefined (reading 'url')}
|
||||
|
||||
ImagePreview: 获取图片数据失败
|
||||
{imageUrl: '/resource/oss/download/1951584287279034370', error: Error: 无效的图片格式: application/octet-stream, 大小: 33347}
|
||||
```
|
||||
|
||||
**OSS模式(通过ossid获取URL)**:
|
||||
```vue
|
||||
<ImagePreview
|
||||
src="123,456,789"
|
||||
:width="200"
|
||||
:height="150"
|
||||
:is-oss-id="true"
|
||||
/>
|
||||
### 解决方案
|
||||
|
||||
#### 1. 修复API响应数据处理 (web/src/components/ImagePreview/index.vue:192-194)
|
||||
```typescript
|
||||
// 修复前
|
||||
const ossList: OssVO[] = response as unknown as OssVO[];
|
||||
|
||||
// 修复后
|
||||
const ossList: OssVO[] = (response as any)?.data || response;
|
||||
```
|
||||
|
||||
### 技术改进
|
||||
#### 2. 增强图片格式验证容错性 (web/src/components/ImagePreview/index.vue:95-111)
|
||||
```typescript
|
||||
// 修复前
|
||||
const validateImageBlob = (blob: Blob): boolean => {
|
||||
const validTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp', 'image/bmp'];
|
||||
return validTypes.includes(blob.type) && blob.size > 0;
|
||||
};
|
||||
|
||||
1. **性能优化**:
|
||||
- 内置URL缓存机制
|
||||
- 避免重复的API调用
|
||||
- 异步加载提升用户体验
|
||||
// 修复后
|
||||
const validateImageBlob = (blob: Blob): boolean => {
|
||||
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;
|
||||
};
|
||||
```
|
||||
|
||||
2. **错误处理**:
|
||||
- 完善的try-catch错误处理
|
||||
- 详细的错误日志记录
|
||||
- 优雅的降级处理
|
||||
#### 3. 放宽Content-Type检查逻辑 (web/src/components/ImagePreview/index.vue:157-161)
|
||||
```typescript
|
||||
// 修复前:拒绝所有非image/*类型
|
||||
if (contentType && !contentType.toLowerCase().startsWith('image/') && !contentType.toLowerCase().includes('octet-stream'))
|
||||
|
||||
3. **用户体验**:
|
||||
- 加载状态显示
|
||||
- 错误状态处理
|
||||
- 支持多张图片预览
|
||||
// 修复后:仅拒绝明确的文本/HTML内容
|
||||
if (contentType &&
|
||||
!contentType.toLowerCase().startsWith('image/') &&
|
||||
!contentType.toLowerCase().includes('octet-stream') &&
|
||||
(contentType.toLowerCase().includes('text/') || contentType.toLowerCase().includes('html')))
|
||||
```
|
||||
|
||||
### 文档更新
|
||||
- 新增 `ImagePreview组件使用说明.md` 文档
|
||||
- 详细说明组件功能、使用方法和注意事项
|
||||
- 提供完整的使用示例和技术实现说明
|
||||
#### 4. 增加OSS文件对象完整性验证 (web/src/components/ImagePreview/index.vue:208-217)
|
||||
```typescript
|
||||
// 额外验证ossFile对象的完整性
|
||||
if (!ossFile || typeof ossFile !== 'object') {
|
||||
throw new Error(`OSS数据格式异常:期望对象,实际获得 ${typeof ossFile}`);
|
||||
}
|
||||
|
||||
if (!ossFile.url) {
|
||||
throw new Error(`OSS文件缺少url属性。完整数据:${JSON.stringify(ossFile)}`);
|
||||
}
|
||||
```
|
||||
|
||||
### 技术细节
|
||||
|
||||
#### RuoYi框架响应格式
|
||||
- **标准响应结构**:`{code: 200, msg: "操作成功", data: [实际数据]}`
|
||||
- **响应拦截器处理**:`service.interceptors.response.use()` 在成功时返回 `res.data`
|
||||
- **正确访问方式**:通过 `response.data` 或兼容性检查 `response.data || response`
|
||||
|
||||
#### OSS服务器特性
|
||||
- **Content-Type问题**:OSS下载接口常返回 `application/octet-stream` 而非具体图片类型
|
||||
- **文件大小验证**:33347字节表明是有效图片文件,不是空响应
|
||||
- **二进制数据处理**:需要正确处理通用二进制格式
|
||||
|
||||
### 验证方法
|
||||
1. **浏览器控制台**:开启debug模式查看详细日志
|
||||
2. **API调用测试**:直接调用 `/resource/oss/listByIds/{ossId}` 验证数据格式
|
||||
3. **图片下载测试**:验证 `/resource/oss/download/{ossId}` 返回有效图片数据
|
||||
|
||||
### 影响范围
|
||||
- ✅ 修复了组件的导入错误
|
||||
- ✅ 增强了OSS ID支持功能
|
||||
- ✅ 提升了用户体验和性能
|
||||
- ✅ 完善了错误处理机制
|
||||
- ✅ 添加了完整的使用文档
|
||||
- **组件**:ImagePreview (`web/src/components/ImagePreview/index.vue`)
|
||||
- **功能**:所有使用OSS图片预览的页面
|
||||
- **向后兼容**:保持对直接URL模式的完整支持
|
||||
|
||||
### 相关文件
|
||||
- `web/src/components/ImagePreview/index.vue` - 主要修复文件
|
||||
- `web/src/views/demo/ImagePreviewTest.vue` - 测试页面(同步修复)
|
||||
- `web/src/api/system/oss/index.ts` - OSS API接口
|
||||
- `web/src/utils/request.ts` - HTTP请求拦截器
|
||||
|
||||
### 测试建议
|
||||
1. 测试普通模式下的图片显示
|
||||
2. 测试OSS模式下的URL获取和图片显示
|
||||
3. 测试多张图片的预览功能
|
||||
4. 测试网络错误情况下的降级处理
|
||||
5. 测试缓存机制的有效性
|
||||
1. 使用实际存在的OSS ID测试图片加载
|
||||
2. 验证不同图片格式的兼容性
|
||||
3. 测试错误处理和调试信息输出
|
||||
4. 确认多图片预览功能正常
|
||||
|
||||
---
|
||||
|
||||
## 历史记录
|
||||
...
|
||||
+145
-2
@@ -398,6 +398,122 @@ def run_all_tests(self):
|
||||
3. **缓存策略**:对频繁查询的字典数据使用 Redis 缓存
|
||||
4. **接口响应优化**:减少不必要的字段传输
|
||||
|
||||
## 📁 文件下载最佳实践 (2025-08-02 新增)
|
||||
|
||||
### 核心问题解决
|
||||
|
||||
基于OSS文件下载功能的修复经验,总结文件下载相关的最佳实践。
|
||||
|
||||
#### 1. 响应头设置规范
|
||||
|
||||
**🔴 关键点:二进制文件下载不要设置charset**
|
||||
|
||||
```java
|
||||
// ❌ 错误方式 - 会导致下载失败
|
||||
response.setContentType(MediaType.APPLICATION_OCTET_STREAM_VALUE + "; charset=UTF-8");
|
||||
|
||||
// ✅ 正确方式 - 纯二进制类型
|
||||
response.setContentType(MediaType.APPLICATION_OCTET_STREAM_VALUE);
|
||||
|
||||
// ✅ 必需的缓存控制头
|
||||
response.setHeader("Cache-Control", "no-cache, no-store, must-revalidate");
|
||||
response.setHeader("Pragma", "no-cache");
|
||||
response.setHeader("Expires", "0");
|
||||
```
|
||||
|
||||
#### 2. Content-Length 正确设置
|
||||
|
||||
```java
|
||||
// ❌ 可能失效的方法引用
|
||||
storage.download(fileName, response.getOutputStream(), response::setContentLengthLong);
|
||||
|
||||
// ✅ 确保安全的设置方式
|
||||
storage.download(fileName, response.getOutputStream(), contentLength -> {
|
||||
if (contentLength != null && contentLength > 0) {
|
||||
response.setContentLengthLong(contentLength);
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
#### 3. 输出流处理
|
||||
|
||||
```java
|
||||
try {
|
||||
// 文件下载逻辑
|
||||
storage.download(fileName, response.getOutputStream(), contentLengthConsumer);
|
||||
|
||||
// 🔴 关键:必须手动刷新输出流
|
||||
response.getOutputStream().flush();
|
||||
|
||||
} catch (Exception e) {
|
||||
throw new ServiceException("文件下载失败: " + e.getMessage());
|
||||
}
|
||||
```
|
||||
|
||||
#### 4. 完整的文件下载模板
|
||||
|
||||
```java
|
||||
@GetMapping("/download/{fileId}")
|
||||
public void downloadFile(@PathVariable Long fileId, HttpServletResponse response) {
|
||||
try {
|
||||
// 1. 权限验证
|
||||
if (!hasDownloadPermission(fileId)) {
|
||||
throw new ServiceException("无权限下载该文件");
|
||||
}
|
||||
|
||||
// 2. 获取文件信息
|
||||
FileVo fileInfo = fileService.getById(fileId);
|
||||
if (fileInfo == null) {
|
||||
throw new ServiceException("文件不存在");
|
||||
}
|
||||
|
||||
// 3. 设置响应头
|
||||
FileUtils.setAttachmentResponseHeader(response, fileInfo.getOriginalName());
|
||||
response.setContentType(MediaType.APPLICATION_OCTET_STREAM_VALUE);
|
||||
response.setHeader("Cache-Control", "no-cache, no-store, must-revalidate");
|
||||
response.setHeader("Pragma", "no-cache");
|
||||
response.setHeader("Expires", "0");
|
||||
|
||||
// 4. 执行下载
|
||||
OssClient storage = OssFactory.instance(fileInfo.getService());
|
||||
storage.download(fileInfo.getFileName(), response.getOutputStream(), contentLength -> {
|
||||
if (contentLength != null && contentLength > 0) {
|
||||
response.setContentLengthLong(contentLength);
|
||||
}
|
||||
});
|
||||
|
||||
// 5. 刷新输出流
|
||||
response.getOutputStream().flush();
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("文件下载失败: {}", e.getMessage(), e);
|
||||
throw new ServiceException("文件下载失败: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 常见问题排查
|
||||
|
||||
#### 问题1:文件传输完成但客户端未收到
|
||||
- **原因**:Content-Type包含charset或缺少flush()
|
||||
- **解决**:移除charset,添加手动flush
|
||||
|
||||
#### 问题2:下载的文件损坏
|
||||
- **原因**:Content-Length设置错误
|
||||
- **解决**:使用lambda表达式确保Content-Length正确设置
|
||||
|
||||
#### 问题3:浏览器缓存导致下载异常
|
||||
- **原因**:缺少缓存控制头
|
||||
- **解决**:设置no-cache相关响应头
|
||||
|
||||
### 测试验证标准
|
||||
|
||||
1. **Postman测试**:能正确下载文件
|
||||
2. **浏览器测试**:点击下载链接正常
|
||||
3. **文件完整性**:下载文件与原文件MD5一致
|
||||
4. **不同文件类型**:图片、文档、压缩包都能正常下载
|
||||
5. **权限控制**:无权限用户被正确拒绝
|
||||
|
||||
## 🔄 持续改进
|
||||
|
||||
### 规范迭代
|
||||
@@ -438,5 +554,32 @@ def run_all_tests(self):
|
||||
**总结**:本最佳实践基于场地预约管理系统的成功开发经验,形成了一套完整的 API 开发标准流程,为后续 API 模块开发提供了可复制的标准模板和质量保证体系。
|
||||
|
||||
**维护人员**:开发团队
|
||||
**最后更新**:2025-07-01
|
||||
**文档版本**:v1.0
|
||||
**最后更新**:2025-08-02
|
||||
**文档版本**:v1.1 (新增文件下载最佳实践)
|
||||
|
||||
## 前端开发最佳实践
|
||||
|
||||
### 🎯 组件开发规范
|
||||
|
||||
#### ImagePreview图片预览组件
|
||||
- **认证图片加载**: 通过axios获取blob数据,确保认证信息传递
|
||||
- **内存管理**: 使用`onBeforeUnmount`清理blob URLs,防止内存泄漏
|
||||
- **缓存优化**: 实现blob URL缓存机制,提升性能
|
||||
|
||||
**核心实现**:
|
||||
```javascript
|
||||
// 认证图片加载
|
||||
const getImageBlobUrl = async (imageUrl: string): Promise<string> => {
|
||||
const response = await request({
|
||||
url: imageUrl,
|
||||
method: 'get',
|
||||
responseType: 'blob'
|
||||
});
|
||||
return URL.createObjectURL(response.data);
|
||||
};
|
||||
|
||||
// 生命周期管理
|
||||
onBeforeUnmount(() => {
|
||||
blobUrls.value.forEach(url => URL.revokeObjectURL(url));
|
||||
});
|
||||
```
|
||||
@@ -6,19 +6,23 @@
|
||||
:preview-src-list="realSrcList"
|
||||
preview-teleported
|
||||
v-loading="loading"
|
||||
@error="handleImageError"
|
||||
@load="handleImageLoad"
|
||||
>
|
||||
<template #error>
|
||||
<div class="image-slot">
|
||||
<el-icon><picture-filled /></el-icon>
|
||||
<div class="error-text">图片加载失败</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-image>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { computed, ref, watch, onBeforeUnmount } from 'vue';
|
||||
import { listByIds } from '@/api/system/oss';
|
||||
import type { OssVO } from '@/api/system/oss/types';
|
||||
import request from '@/utils/request';
|
||||
|
||||
const props = defineProps({
|
||||
src: {
|
||||
@@ -42,28 +46,159 @@ const props = defineProps({
|
||||
|
||||
const loading = ref(false);
|
||||
const urlCache = ref<Map<string, string>>(new Map());
|
||||
const blobUrls = ref<string[]>([]);
|
||||
|
||||
// 图片加载成功事件
|
||||
const handleImageLoad = () => {
|
||||
// 图片加载成功
|
||||
};
|
||||
|
||||
// 图片加载失败事件
|
||||
const handleImageError = (error: any) => {
|
||||
// 图片加载失败处理
|
||||
};
|
||||
|
||||
// 清理blob URL,防止内存泄漏
|
||||
const cleanupBlobUrls = () => {
|
||||
blobUrls.value.forEach((url) => {
|
||||
URL.revokeObjectURL(url);
|
||||
});
|
||||
blobUrls.value = [];
|
||||
urlCache.value.clear();
|
||||
};
|
||||
|
||||
// 组件卸载时清理blob URLs
|
||||
onBeforeUnmount(() => {
|
||||
cleanupBlobUrls();
|
||||
});
|
||||
|
||||
// 验证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 getImageBlobUrl = async (imageUrl: string): Promise<string> => {
|
||||
try {
|
||||
// 检查是否已有缓存的blob URL
|
||||
const cachedUrl = urlCache.value.get(imageUrl);
|
||||
if (cachedUrl) {
|
||||
return cachedUrl;
|
||||
}
|
||||
|
||||
// 通过axios请求图片数据,这样会自动带上token和clientId
|
||||
const response = await request({
|
||||
url: imageUrl,
|
||||
method: 'get',
|
||||
responseType: 'blob',
|
||||
timeout: 30000 // 30秒超时
|
||||
});
|
||||
|
||||
// 由于axios响应拦截器对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
|
||||
urlCache.value.set(imageUrl, blobUrl);
|
||||
blobUrls.value.push(blobUrl);
|
||||
|
||||
return blobUrl;
|
||||
} catch (error) {
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
// 通过ossid获取真实URL
|
||||
const getUrlByOssId = async (ossId: string): Promise<string> => {
|
||||
// 检查缓存
|
||||
if (urlCache.value.has(ossId)) {
|
||||
return urlCache.value.get(ossId)!;
|
||||
}
|
||||
|
||||
try {
|
||||
loading.value = true;
|
||||
const response = await listByIds(ossId);
|
||||
const ossList: OssVO[] = response.data;
|
||||
|
||||
if (ossList && ossList.length > 0) {
|
||||
const url = ossList[0].url;
|
||||
// 缓存结果
|
||||
urlCache.value.set(ossId, url);
|
||||
return url;
|
||||
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} 在数据库中不存在或已被删除。可能原因:1) OSS ID不正确 2) 文件已被删除 3) 权限不足`);
|
||||
}
|
||||
return '';
|
||||
|
||||
const ossFile = ossList[0];
|
||||
|
||||
// 额外验证ossFile对象的完整性
|
||||
if (!ossFile || typeof ossFile !== 'object') {
|
||||
throw new Error(`OSS数据格式异常:期望对象,实际获得 ${typeof ossFile}`);
|
||||
}
|
||||
|
||||
if (!ossFile.url) {
|
||||
throw new Error(`OSS文件缺少url属性。完整数据:${JSON.stringify(ossFile)}`);
|
||||
}
|
||||
const imageUrl = ossFile.url;
|
||||
|
||||
// 检查URL是否有效
|
||||
if (!imageUrl || typeof imageUrl !== 'string') {
|
||||
throw new Error(`OSS接口返回的URL无效。URL: ${imageUrl}, 类型: ${typeof imageUrl}`);
|
||||
}
|
||||
|
||||
// 如果URL不是以/开头的下载路径,可能是直接的OSS URL
|
||||
if (!imageUrl.startsWith('/resource/oss/download/')) {
|
||||
// 对于直接的OSS URL,我们仍然通过认证请求获取
|
||||
}
|
||||
|
||||
// 获取认证的blob URL
|
||||
return await getImageBlobUrl(imageUrl);
|
||||
} catch (error) {
|
||||
console.error('获取OSS URL失败:', error);
|
||||
return '';
|
||||
} finally {
|
||||
loading.value = false;
|
||||
@@ -86,42 +221,73 @@ const getUrlsByOssIds = async (ossIds: string[]): Promise<string[]> => {
|
||||
return urls;
|
||||
};
|
||||
|
||||
const realSrc = ref('');
|
||||
// 处理普通URL模式(非ossId)
|
||||
const getUrlsByDirectUrls = async (urls: string[]): Promise<string[]> => {
|
||||
const blobUrls: string[] = [];
|
||||
|
||||
for (const url of urls) {
|
||||
if (url.trim()) {
|
||||
const blobUrl = await getImageBlobUrl(url.trim());
|
||||
if (blobUrl) {
|
||||
blobUrls.push(blobUrl);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return blobUrls;
|
||||
};
|
||||
|
||||
const realSrc = ref('');
|
||||
const realSrcList = ref<string[]>([]);
|
||||
|
||||
// 处理src变化
|
||||
watch(
|
||||
() => props.src,
|
||||
async (newSrc) => {
|
||||
// 清理之前的blob URLs
|
||||
cleanupBlobUrls();
|
||||
|
||||
if (!newSrc) {
|
||||
realSrc.value = '';
|
||||
realSrcList.value = [];
|
||||
return;
|
||||
}
|
||||
|
||||
if (props.isOssId) {
|
||||
// ossid模式:通过接口获取真实URL
|
||||
const ossIds = newSrc.split(',');
|
||||
const urls = await getUrlsByOssIds(ossIds);
|
||||
loading.value = true;
|
||||
|
||||
if (urls.length > 0) {
|
||||
realSrc.value = urls[0];
|
||||
realSrcList.value = urls;
|
||||
try {
|
||||
if (props.isOssId) {
|
||||
// ossid模式:通过接口获取真实URL,然后转换为认证的blob URL
|
||||
const ossIds = newSrc.split(',').filter((id) => id.trim());
|
||||
|
||||
const urls = await getUrlsByOssIds(ossIds);
|
||||
|
||||
if (urls.length > 0) {
|
||||
realSrc.value = urls[0];
|
||||
realSrcList.value = urls;
|
||||
} else {
|
||||
realSrc.value = '';
|
||||
realSrcList.value = [];
|
||||
}
|
||||
} else {
|
||||
realSrc.value = '';
|
||||
realSrcList.value = [];
|
||||
}
|
||||
} else {
|
||||
// 普通模式:直接使用src作为URL
|
||||
const urls = newSrc.split(',').filter((url) => url.trim());
|
||||
if (urls.length > 0) {
|
||||
realSrc.value = urls[0];
|
||||
realSrcList.value = urls;
|
||||
} else {
|
||||
realSrc.value = '';
|
||||
realSrcList.value = [];
|
||||
// 普通模式:直接使用src作为URL,但仍需要通过认证请求转换为blob URL
|
||||
const urls = newSrc.split(',').filter((url) => url.trim());
|
||||
|
||||
const blobUrls = await getUrlsByDirectUrls(urls);
|
||||
|
||||
if (blobUrls.length > 0) {
|
||||
realSrc.value = blobUrls[0];
|
||||
realSrcList.value = blobUrls;
|
||||
} else {
|
||||
realSrc.value = '';
|
||||
realSrcList.value = [];
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
realSrc.value = '';
|
||||
realSrcList.value = [];
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
@@ -149,12 +315,19 @@ const realHeight = computed(() => (typeof props.height == 'string' ? props.heigh
|
||||
|
||||
:deep(.image-slot) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
color: #909399;
|
||||
font-size: 30px;
|
||||
|
||||
.error-text {
|
||||
font-size: 12px;
|
||||
margin-top: 8px;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,261 @@
|
||||
<template>
|
||||
<div class="image-preview-test">
|
||||
<el-card header="图片预览组件测试">
|
||||
<el-form :model="testForm" label-width="120px">
|
||||
<el-form-item label="图片源:">
|
||||
<el-input v-model="testForm.src" placeholder="输入OSS ID或图片URL" clearable />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="模式:">
|
||||
<el-radio-group v-model="testForm.isOssId">
|
||||
<el-radio :label="true">OSS ID模式</el-radio>
|
||||
<el-radio :label="false">直接URL模式</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="调试模式:">
|
||||
<el-switch v-model="testForm.debug" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="尺寸:">
|
||||
<el-input v-model="testForm.width" placeholder="宽度" style="width: 100px" />
|
||||
<span style="margin: 0 10px">×</span>
|
||||
<el-input v-model="testForm.height" placeholder="高度" style="width: 100px" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="testImage">测试图片</el-button>
|
||||
<el-button @click="checkOssId" :loading="checkingOssId">检查OSS ID</el-button>
|
||||
<el-button @click="clearTest">清除</el-button>
|
||||
<el-button @click="testDefaultOssId">测试默认OSS ID</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<el-divider content-position="left">预览结果</el-divider>
|
||||
|
||||
<div class="preview-container">
|
||||
<ImagePreview
|
||||
v-if="showPreview"
|
||||
:src="testForm.src"
|
||||
:is-oss-id="testForm.isOssId"
|
||||
:width="testForm.width || 200"
|
||||
:height="testForm.height || 200"
|
||||
:debug="testForm.debug"
|
||||
/>
|
||||
<div v-else class="no-preview">
|
||||
<el-icon><picture /></el-icon>
|
||||
<p>请输入图片源进行测试</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-divider content-position="left">调试指南</el-divider>
|
||||
|
||||
<div class="debug-guide">
|
||||
<el-alert title="调试步骤" type="info" show-icon :closable="false">
|
||||
<template #default>
|
||||
<p>1. 打开浏览器开发者工具 → Console 面板</p>
|
||||
<p>2. 开启"调试模式"可以看到详细的执行日志</p>
|
||||
<p>3. 观察 [ImagePreview Debug] 开头的日志信息</p>
|
||||
<p>4. 查看是否有错误信息(红色日志)</p>
|
||||
</template>
|
||||
</el-alert>
|
||||
|
||||
<el-card style="margin-top: 15px" header="常见问题排查">
|
||||
<el-collapse>
|
||||
<el-collapse-item title="1. OSS接口调用失败" name="1">
|
||||
<p><strong>现象:</strong>控制台显示 "获取OSS URL失败"</p>
|
||||
<p><strong>排查:</strong>检查OSS ID是否正确,后端接口是否正常</p>
|
||||
<p><strong>解决:</strong>确认 /resource/oss/listByIds/{ossId} 接口可以正常访问</p>
|
||||
</el-collapse-item>
|
||||
|
||||
<el-collapse-item title="2. 图片请求权限问题" name="2">
|
||||
<p><strong>现象:</strong>控制台显示 401/403 错误</p>
|
||||
<p><strong>排查:</strong>检查token是否有效,clientId是否正确</p>
|
||||
<p><strong>解决:</strong>确认request拦截器正确添加了Authorization头</p>
|
||||
</el-collapse-item>
|
||||
|
||||
<el-collapse-item title="3. blob URL创建失败" name="3">
|
||||
<p><strong>现象:</strong>控制台显示 "无效的图片格式"</p>
|
||||
<p><strong>排查:</strong>检查图片URL返回的内容类型</p>
|
||||
<p><strong>解决:</strong>确认返回的是真实图片数据,不是HTML错误页面</p>
|
||||
</el-collapse-item>
|
||||
|
||||
<el-collapse-item title="4. CORS跨域问题" name="4">
|
||||
<p><strong>现象:</strong>控制台显示CORS相关错误</p>
|
||||
<p><strong>排查:</strong>检查图片服务器的CORS配置</p>
|
||||
<p><strong>解决:</strong>配置后端代理或修改CORS策略</p>
|
||||
</el-collapse-item>
|
||||
</el-collapse>
|
||||
</el-card>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue';
|
||||
import ImagePreview from '@/components/ImagePreview/index.vue';
|
||||
import { Picture } from '@element-plus/icons-vue';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import { listByIds } from '@/api/system/oss';
|
||||
|
||||
const testForm = ref({
|
||||
src: '',
|
||||
isOssId: true,
|
||||
debug: true,
|
||||
width: '200',
|
||||
height: '200'
|
||||
});
|
||||
|
||||
const checkingOssId = ref(false);
|
||||
|
||||
const showPreview = computed(() => {
|
||||
return testForm.value.src && testForm.value.src.trim() !== '';
|
||||
});
|
||||
|
||||
const testImage = () => {
|
||||
if (!testForm.value.src) {
|
||||
ElMessage.warning('请输入图片源');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('='.repeat(50));
|
||||
console.log('开始测试图片预览组件');
|
||||
console.log('测试参数:', {
|
||||
src: testForm.value.src,
|
||||
isOssId: testForm.value.isOssId,
|
||||
debug: testForm.value.debug,
|
||||
width: testForm.value.width,
|
||||
height: testForm.value.height
|
||||
});
|
||||
console.log('='.repeat(50));
|
||||
};
|
||||
|
||||
const checkOssId = async () => {
|
||||
if (!testForm.value.src) {
|
||||
ElMessage.warning('请输入OSS ID');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!testForm.value.isOssId) {
|
||||
ElMessage.warning('请切换到OSS ID模式进行检查');
|
||||
return;
|
||||
}
|
||||
|
||||
checkingOssId.value = true;
|
||||
|
||||
try {
|
||||
console.log('='.repeat(50));
|
||||
console.log('检查OSS ID:', testForm.value.src);
|
||||
|
||||
const response = await listByIds(testForm.value.src);
|
||||
// RuoYi框架的响应格式:{code, msg, data},实际数据在data字段中
|
||||
console.log('API响应原始数据:', response);
|
||||
const ossList = response.data || response;
|
||||
|
||||
console.log('OSS检查结果:', {
|
||||
dataLength: ossList?.length || 0,
|
||||
data: ossList
|
||||
});
|
||||
|
||||
if (!ossList || ossList.length === 0) {
|
||||
ElMessage.error(`OSS ID ${testForm.value.src} 在数据库中不存在`);
|
||||
console.error('OSS ID不存在:', testForm.value.src);
|
||||
} else {
|
||||
const ossFile = ossList[0];
|
||||
ElMessage.success(`OSS ID ${testForm.value.src} 存在`);
|
||||
console.log('OSS文件信息:', {
|
||||
ossId: ossFile.ossId,
|
||||
fileName: ossFile.fileName,
|
||||
originalName: ossFile.originalName,
|
||||
fileSuffix: ossFile.fileSuffix,
|
||||
url: ossFile.url,
|
||||
createTime: ossFile.createTime,
|
||||
service: ossFile.service
|
||||
});
|
||||
|
||||
// 检查文件类型
|
||||
if (ossFile.fileSuffix && !ossFile.fileSuffix.toLowerCase().match(/\.(jpg|jpeg|png|gif|webp|bmp)$/)) {
|
||||
ElMessage.warning(`文件类型 ${ossFile.fileSuffix} 可能不是图片格式`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('='.repeat(50));
|
||||
} catch (error) {
|
||||
console.error('检查OSS ID失败:', error);
|
||||
ElMessage.error(`检查OSS ID失败: ${error.message}`);
|
||||
} finally {
|
||||
checkingOssId.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const clearTest = () => {
|
||||
testForm.value.src = '';
|
||||
console.clear();
|
||||
ElMessage.success('已清除测试数据和控制台日志');
|
||||
};
|
||||
|
||||
const testDefaultOssId = () => {
|
||||
// 这里可以设置一个测试用的OSS ID
|
||||
testForm.value.src = '1'; // 请替换为实际存在的OSS ID
|
||||
testForm.value.isOssId = true;
|
||||
testForm.value.debug = true;
|
||||
|
||||
ElMessage.info('已设置默认测试OSS ID,请根据实际情况修改');
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.image-preview-test {
|
||||
padding: 20px;
|
||||
|
||||
.preview-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 250px;
|
||||
border: 2px dashed #dcdfe6;
|
||||
border-radius: 8px;
|
||||
background-color: #fafafa;
|
||||
|
||||
.no-preview {
|
||||
text-align: center;
|
||||
color: #909399;
|
||||
|
||||
.el-icon {
|
||||
font-size: 48px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.debug-guide {
|
||||
.el-alert {
|
||||
:deep(.el-alert__content) {
|
||||
p {
|
||||
margin: 5px 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.el-collapse {
|
||||
:deep(.el-collapse-item__content) {
|
||||
p {
|
||||
margin: 8px 0;
|
||||
line-height: 1.6;
|
||||
|
||||
strong {
|
||||
color: #409eff;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,118 @@
|
||||
<template>
|
||||
<div class="image-upload-test">
|
||||
<el-card header="ImageUpload组件测试 - 认证图片回显和预览">
|
||||
<el-form :model="form" label-width="120px">
|
||||
<el-form-item label="测试数据1">
|
||||
<el-button @click="loadTestData1" type="primary">加载单个OSS ID</el-button>
|
||||
<span style="margin-left: 10px; color: #666;">测试单个OSS文件回显</span>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="测试数据2">
|
||||
<el-button @click="loadTestData2" type="primary">加载多个OSS ID</el-button>
|
||||
<span style="margin-left: 10px; color: #666;">测试多个OSS文件回显</span>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="测试数据3">
|
||||
<el-button @click="loadTestData3" type="primary">加载URL格式</el-button>
|
||||
<span style="margin-left: 10px; color: #666;">测试直接URL回显</span>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="清空数据">
|
||||
<el-button @click="clearData" type="danger">清空</el-button>
|
||||
<span style="margin-left: 10px; color: #666;">清空所有图片数据</span>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="当前值">
|
||||
<el-input v-model="form.images" type="textarea" :rows="3" readonly />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="图片上传">
|
||||
<ImageUpload v-model="form.images" :limit="5" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<div class="test-info">
|
||||
<h3>测试说明</h3>
|
||||
<ul>
|
||||
<li><strong>功能验证</strong>:
|
||||
<ol>
|
||||
<li>界面打开时历史图片是否正常显示(通过认证接口获取blob URL)</li>
|
||||
<li>点击图片预览是否正常工作</li>
|
||||
<li>上传新图片功能是否正常</li>
|
||||
<li>删除图片功能是否正常</li>
|
||||
</ol>
|
||||
</li>
|
||||
<li><strong>技术要点</strong>:
|
||||
<ul>
|
||||
<li>使用 Promise.all 并行处理多个文件的blob URL转换</li>
|
||||
<li>缓存机制避免重复请求相同图片的认证数据</li>
|
||||
<li>降级处理:认证失败时使用原始URL</li>
|
||||
<li>完整的blob URL生命周期管理,防止内存泄漏</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li><strong>错误处理</strong>:如果图片无法显示,检查控制台错误信息</li>
|
||||
</ul>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive } from 'vue';
|
||||
import ImageUpload from '@/components/ImageUpload/index.vue';
|
||||
|
||||
const form = reactive({
|
||||
images: ''
|
||||
});
|
||||
|
||||
// 测试数据1:单个OSS ID
|
||||
const loadTestData1 = () => {
|
||||
form.images = '1951584287279034370'; // 示例OSS ID,实际使用时需要替换为有效的OSS ID
|
||||
};
|
||||
|
||||
// 测试数据2:多个OSS ID
|
||||
const loadTestData2 = () => {
|
||||
form.images = '1951584287279034370,1951584287279034371'; // 示例多个OSS ID
|
||||
};
|
||||
|
||||
// 测试数据3:URL格式
|
||||
const loadTestData3 = () => {
|
||||
form.images = '/resource/oss/download/1951584287279034370'; // 示例OSS下载URL
|
||||
};
|
||||
|
||||
// 清空数据
|
||||
const clearData = () => {
|
||||
form.images = '';
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.image-upload-test {
|
||||
padding: 20px;
|
||||
|
||||
.test-info {
|
||||
margin-top: 30px;
|
||||
padding: 20px;
|
||||
background-color: #f5f5f5;
|
||||
border-radius: 4px;
|
||||
|
||||
h3 {
|
||||
margin-top: 0;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
ul, ol {
|
||||
margin-left: 20px;
|
||||
|
||||
li {
|
||||
margin-bottom: 8px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
}
|
||||
|
||||
strong {
|
||||
color: #409EFF;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in new issue
Block a user