主要后端使用Java实现,前端可随意搭配http请求
添加依赖:
XML
<!-- 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>
控制层代码实现:
java
@CrossOrigin
@RestController
@RequestMapping("/tool")
public class ToolsController {
@Autowired
private 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文件打包成ZIP
try (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层代码实现
java
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;
}
java
@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字节数组的映射
*/
@Override
public 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();
}
}
前端实现:
TypeScript
<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-upload
ref="upload"
class="upload-area"
action="#"
:http-request="handleUpload"
:on-change="handleFileChange"
:on-remove="handleFileRemove"
:before-upload="beforeUpload"
:file-list="fileList"
:auto-upload="false"
multiple
accept=".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"
border
size="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-progress
v-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-button
v-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-button
v-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-dialog
title="转换进度"
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-button
type="default"
@click="cancelConversion"
:disabled="!isCancellable"
>
取消转换
</el-button>
</template>
</el-dialog>
<!-- 转换完成提示 -->
<el-dialog
title="转换完成"
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-button
type="primary"
@click="downloadAllFiles"
:disabled="successCount === 0"
>
<el-icon class="mr-1"><Download /></el-icon>下载全部
</el-button>
<el-button
type="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 {
// 创建FormData
const 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;
// 创建下载URL
file.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>