主要后端使用Java实现,前端可随意搭配http请求
添加依赖:
<!-- OFD解析与转换库 --><dependency><groupId>org.ofdrw</groupId><artifactId>ofdrw-converter</artifactId><version>1.17.9</version></dependency><!-- PDFBox用于PDF生成 --><dependency><groupId>org.apache.pdfbox</groupId><artifactId>pdfbox</artifactId><version>2.0.29</version></dependency>
控制层代码实现:
@CrossOrigin
@RestController
@RequestMapping("/tool")
public class ToolsController {@Autowiredprivate ToolsService toolsService;/*** 批量转换OFD文件为PDF并打包下载*/@PostMapping("/batchofd2pdf")public ResponseEntity<byte[]> batchConvert(@RequestParam("files") MultipartFile[] ofdFiles) {if (ofdFiles == null || ofdFiles.length == 0) {return new ResponseEntity<>(HttpStatus.BAD_REQUEST);}try {// 构建文件名到输入流的映射Map<String, InputStream> fileMap = new HashMap<>();for (MultipartFile file : ofdFiles) {if (!file.isEmpty() && file.getOriginalFilename().toLowerCase().endsWith(".ofd")) {fileMap.put(file.getOriginalFilename(), file.getInputStream());}}// 执行批量转换Map<String, byte[]> pdfFiles = toolsService.batchConvert(fileMap);// 将所有PDF文件打包成ZIPtry (ByteArrayOutputStream byteOut = new ByteArrayOutputStream();ZipOutputStream zipOut = new ZipOutputStream(byteOut)) {for (Map.Entry<String, byte[]> entry : pdfFiles.entrySet()) {zipOut.putNextEntry(new ZipEntry(entry.getKey()));zipOut.write(entry.getValue());zipOut.closeEntry();}zipOut.finish();// 设置响应头,返回ZIP文件HttpHeaders headers = new HttpHeaders();headers.setContentDispositionFormData("attachment", "ofd_converted_pdfs.zip");headers.setContentType(MediaType.APPLICATION_OCTET_STREAM);return new ResponseEntity<>(byteOut.toByteArray(), headers, HttpStatus.OK);}} catch (Exception e) {e.printStackTrace();return new ResponseEntity<>(HttpStatus.INTERNAL_SERVER_ERROR);}}
}
service层代码实现
package com.tool.service;import java.io.ByteArrayOutputStream;
import java.io.InputStream;
import java.util.Map;
import java.util.concurrent.ExecutionException;public interface ToolsService {Map<String,byte[]> batchConvert(Map<String, InputStream> fileMap) throws InterruptedException, ExecutionException;
}
@Service
public class ToolsServiceImpl implements ToolsService {// 线程池用于并行处理转换任务private final ExecutorService executorService = Executors.newFixedThreadPool(Runtime.getRuntime().availableProcessors() + 1);/*** 单个文件转换:输入流到输出流*/public void convertOfdToPdf(InputStream ofdInputStream, OutputStream pdfOutputStream) throws Exception {ConvertHelper.toPdf(ofdInputStream, pdfOutputStream);}/*** 批量转换多个OFD文件* @param fileMap 文件名到输入流的映射* @return 文件名到PDF字节数组的映射*/@Overridepublic Map<String, byte[]> batchConvert(Map<String, InputStream> fileMap) throws InterruptedException, ExecutionException {Map<String, Future<byte[]>> futures = new HashMap<>();// 提交所有转换任务到线程池for (Map.Entry<String, InputStream> entry : fileMap.entrySet()) {String fileName = entry.getKey();InputStream inputStream = entry.getValue();futures.put(fileName, executorService.submit(() -> {try (ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) {convertOfdToPdf(inputStream, outputStream);return outputStream.toByteArray();} finally {inputStream.close();}}));}// 收集转换结果Map<String, byte[]> results = new HashMap<>();for (Map.Entry<String, Future<byte[]>> entry : futures.entrySet()) {String fileName = entry.getKey().replace(".ofd", ".pdf");results.put(fileName, entry.getValue().get());}return results;}/*** 应用关闭时关闭线程池*/public void shutdownExecutor() {executorService.shutdown();}
}
前端实现:
<template><div class="container mx-auto px-4 py-8 max-w-6xl"><!-- 页面标题 --><div class="text-center mb-8"><h1 class="text-[clamp(1.8rem,4vw,2.5rem)] font-bold text-gray-800 mb-2">OFD转PDF批量转换</h1><p class="text-gray-500">支持多文件上传,一键批量转换OFD文件为PDF格式</p></div><!-- 上传区域 --><div class="p-6" style="margin: 10px 10px 10px 10px;"><el-uploadref="upload"class="upload-area"action="#":http-request="handleUpload":on-change="handleFileChange":on-remove="handleFileRemove":before-upload="beforeUpload":file-list="fileList":auto-upload="false"multipleaccept=".ofd"><el-button type="primary" :icon="Upload">选择文件</el-button><template #tip><div class="el-upload__tip text-sm text-gray-500">支持上传多个文件</div></template></el-upload></div><!-- 文件列表和进度 --><el-card v-if="fileList.length > 0" class="mb-6 transition-all duration-300 hover:shadow-md"><div class="p-4 border-b"><h2 class="font-semibold text-gray-800">文件列表</h2></div><el-table:data="fileList"bordersize="small"class="mb-0"><el-table-column prop="name" label="文件名" width="350"></el-table-column><el-table-column prop="size" label="大小" width="120"><template #default="scope">{{ formatFileSize(scope.row.size) }}</template></el-table-column><el-table-column prop="status" label="状态" width="150"><template #default="scope"><el-tag:type="scope.row.status === 'ready' ? 'info' :scope.row.status === 'waiting' ? 'info' :scope.row.status === 'converting' ? 'warning' :scope.row.status === 'success' ? 'success' : 'danger'"size="small"><el-icon v-if="scope.row.status === 'converting'" class="mr-1"><Loading /></el-icon>{{ statusMap[scope.row.status] }}</el-tag></template></el-table-column><el-table-column label="进度" width="200"><template #default="scope"><el-progressv-if="scope.row.status === 'converting'":percentage="scope.row.progress"stroke-width="6"size="small"></el-progress><span v-else-if="scope.row.status === 'success'">100%</span><span v-else>-</span></template></el-table-column><el-table-column label="操作" width="120"><template #default="scope"><el-buttonv-if="scope.row.status === 'success'"type="text"size="small"text-color="#165DFF"@click="downloadFile(scope.row)"><el-icon class="mr-1"><Download /></el-icon>下载</el-button><el-buttonv-else-if="scope.row.status === 'waiting' || scope.row.status === 'error'"type="text"size="small"text-color="#F53F3F"@click="handleFileRemove(scope.row)"><el-icon class="mr-1"><Delete /></el-icon>删除</el-button><span v-else>-</span></template></el-table-column></el-table></el-card><!-- 转换进度弹窗 --><el-dialogtitle="转换进度"v-model="showProgressDialog":close-on-click-modal="false":show-close="false"width="500px"><div class="mb-4"><p class="text-gray-600 mb-2">总进度:{{ totalProgress }}%</p><el-progress :percentage="totalProgress" stroke-width="8"></el-progress></div><div v-for="file in fileList" :key="file.uid" class="mb-2"><div class="flex justify-between text-sm mb-1"><span>{{ file.name }}</span><span>{{ file.progress }}%</span></div><el-progress :percentage="file.progress" stroke-width="4" size="small"></el-progress></div><template #footer><el-buttontype="default"@click="cancelConversion":disabled="!isCancellable">取消转换</el-button></template></el-dialog><!-- 转换完成提示 --><el-dialogtitle="转换完成"v-model="showCompleteDialog"width="400px"><div class="text-center py-4">
<!-- <el-icon class="text-5xl text-success mb-4"><CheckCircle /></el-icon>--><p>所有文件转换已完成</p><p class="text-gray-500 mt-2">成功:{{ successCount }} 个,失败:{{ errorCount }} 个</p></div><template #footer><div class="text-center"><el-buttontype="primary"@click="downloadAllFiles":disabled="successCount === 0"><el-icon class="mr-1"><Download /></el-icon>下载全部</el-button><el-buttontype="default"@click="showCompleteDialog = false">关闭</el-button></div></template></el-dialog></div>
</template><script setup>
import { ref, computed, onBeforeUnmount } from 'vue';
import {DocumentAdd, Upload, Loading, Delete, Download
} from '@element-plus/icons-vue';
import {ElButton, ElMessage, ElNotification} from 'element-plus';
import axios from 'axios';// 文件列表
const fileList = ref([]);
// 上传状态
const isUploading = ref(false);
// 转换状态
const isConverting = ref(false);
// 进度弹窗显示
const showProgressDialog = ref(false);
// 完成弹窗显示
const showCompleteDialog = ref(false);
// 上传组件引用
const upload = ref(null);
// 转换请求取消令牌
const cancelTokenSource = ref(null);// 状态映射
const statusMap = {ready: '等待转换',waiting: '等待转换',converting: '转换中',success: '转换成功',error: '转换失败'
};// 修复:转换按钮是否禁用的计算属性
const isConvertDisabled = computed(() => {// 当没有文件、正在上传或正在转换时禁用return fileList.value.length === 0 || isUploading.value || isConverting.value;
});// 计算属性:总进度
const totalProgress = computed(() => {if (fileList.value.length === 0) return 0;const sum = fileList.value.reduce((acc, file) => acc + file.progress, 0);return Math.round(sum / fileList.value.length);
});// 计算属性:成功和失败数量
const successCount = computed(() => {return fileList.value.filter(file => file.status === 'success').length;
});const errorCount = computed(() => {return fileList.value.filter(file => file.status === 'error').length;
});// 计算属性:是否可取消
const isCancellable = computed(() => {return isConverting.value && totalProgress.value < 100;
});// 文件大小格式化
const formatFileSize = (bytes) => {if (bytes === 0) return '0 B';const k = 1024;const sizes = ['B', 'KB', 'MB', 'GB'];const i = Math.floor(Math.log(bytes) / Math.log(k));return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
};// 上传前检查
const beforeUpload = (file) => {// 检查文件类型if (file.type !== '' && !file.name.toLowerCase().endsWith('.ofd')) {ElMessage.error('请上传OFD格式的文件');return false;}// 检查文件大小(限制50MB)const maxSize = 50 * 1024 * 1024;if (file.size > maxSize) {ElMessage.error('文件大小不能超过50MB');return false;}return true;
};// 文件变化处理 - 修复:确保文件正确添加到列表
const handleFileChange = (file, newFileList) => {// 同步更新文件列表fileList.value = newFileList;// 为新添加的文件设置初始状态if (!file.status) {file.status = 'waiting';file.progress = 0;file.pdfUrl = null;}
};// 移除文件
const handleFileRemove = (file) => {fileList.value = fileList.value.filter(item => item.uid !== file.uid);
};// 清空文件列表
const clearFiles = () => {fileList.value = [];if (upload.value) {upload.value.clearFiles();}
};// 处理上传(覆盖默认上传行为)
const handleUpload = () => {// 实际上传由submitUpload处理,这里只是为了满足组件要求
};// 提交转换 - 修复:状态管理更清晰
const submitUpload = async () => {console.log(1)if (fileList.value.length === 0) {ElMessage.warning('请先选择文件');return;}console.log(2)// 重置文件状态fileList.value.forEach(file => {file.status = 'converting';file.progress = 0;});console.log(3)// 更新状态变量isUploading.value = true;isConverting.value = true;showProgressDialog.value = true;console.log(4)try {// 创建FormDataconst formData = new FormData();fileList.value.forEach(file => {formData.append('files', file.raw);});console.log(5)// 创建取消令牌cancelTokenSource.value = axios.CancelToken.source();console.log(6)// 模拟进度更新(实际项目中可以通过WebSocket或轮询实现)const progressInterval = setInterval(() => {fileList.value.forEach(file => {if (file.status === 'converting' && file.progress < 100) {// 随机增加进度,模拟真实场景const increment = Math.floor(Math.random() * 5) + 1;file.progress = Math.min(file.progress + increment, 100);}});}, 300);console.log(7)const postUrl = `http://10.60.128.250:8080/tool/batchofd2pdf`// 发送请求const response = await axios.post(postUrl, formData, {responseType: 'blob',cancelToken: cancelTokenSource.value.token,headers: {'Content-Type': 'multipart/form-data'}});console.log(8)// 清除进度模拟clearInterval(progressInterval);console.log(9)// 更新所有文件状态为成功fileList.value.forEach(file => {file.status = 'success';file.progress = 100;// 创建下载URLfile.pdfUrl = URL.createObjectURL(response.data);});console.log(10)// 显示完成弹窗showProgressDialog.value = false;showCompleteDialog.value = true;console.log(11)ElNotification.success({title: '转换成功',message: `已成功转换 ${fileList.value.length} 个文件`,duration: 3000});console.log(12)} catch (error) {if (axios.isCancel(error)) {// 取消操作fileList.value.forEach(file => {if (file.status === 'converting') {file.status = 'waiting';}});ElMessage.info('已取消转换');} else {// 错误处理fileList.value.forEach(file => {if (file.status === 'converting') {file.status = 'error';}});ElMessage.error('转换失败:' + (error.response?.data?.message || error.message));}} finally {// 重置状态变量console.log(14)isUploading.value = false;isConverting.value = false;showProgressDialog.value = false;console.log(15)}
};// 取消转换
const cancelConversion = () => {if (cancelTokenSource.value) {cancelTokenSource.value.cancel('用户取消了转换');}
};// 下载单个文件
const downloadFile = (file) => {if (!file.pdfUrl) {ElMessage.warning('文件下载地址不存在');return;}// 创建a标签下载const link = document.createElement('a');link.href = file.pdfUrl;link.download = file.name.replace('.ofd', '.pdf');document.body.appendChild(link);link.click();document.body.removeChild(link);
};// 下载所有文件
const downloadAllFiles = () => {// 这里应该下载ZIP包const firstPdfFile = fileList.value.find(file => file.status === 'success');if (firstPdfFile?.pdfUrl) {const link = document.createElement('a');link.href = firstPdfFile.pdfUrl;link.download = 'ofd_converted_pdfs.zip';document.body.appendChild(link);link.click();document.body.removeChild(link);showCompleteDialog.value = false;}
};// 组件卸载前清理
onBeforeUnmount(() => {// 释放URL对象fileList.value.forEach(file => {if (file.pdfUrl) {URL.revokeObjectURL(file.pdfUrl);}});// 取消请求if (cancelTokenSource.value) {cancelTokenSource.value.cancel('组件已卸载');}
});
defineExpose({submitUpload,clearFiles
});
</script><style scoped>
.upload-area {border: 1px dashed #ccc;border-radius: 4px;padding: 20px;text-align: center;transition: border-color 0.3s;
}.upload-area:hover {border-color: #409eff;
}.upload-dropzone {transition: all 0.3s ease;
}::v-deep .el-progress__text {font-size: 12px !important;
}::v-deep .el-table__row:hover {background-color: #f5f7fa !important;
}
</style>