feat: add ONLYOFFICE document preview for contract attachments

DocumentPreviewController provides preview config and file streaming endpoints. ContractDetailView adds 'preview' button to attachment list and opens ONLYOFFICE iframe dialog.

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
2026-05-27 08:37:09 +08:00
parent 8c788ea388
commit 8ee9aa51d8
2 changed files with 104 additions and 2 deletions
@@ -0,0 +1,84 @@
package cn.craftlabs.platform.api.preview;
import cn.craftlabs.platform.api.persistence.attachment.PlatformContractAttachment;
import cn.craftlabs.platform.api.persistence.attachment.PlatformContractAttachmentMapper;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.FileSystemResource;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
import java.util.Map;
@RestController
@RequestMapping("/api/v1/preview")
public class DocumentPreviewController {
private final PlatformContractAttachmentMapper attachmentMapper;
@Value("${onlyoffice.url:http://craftsupport.cn:8088}")
private String onlyofficeUrl;
public DocumentPreviewController(PlatformContractAttachmentMapper attachmentMapper) {
this.attachmentMapper = attachmentMapper;
}
@GetMapping("/{attachmentId}")
public ResponseEntity<Map<String, Object>> getPreviewConfig(@PathVariable("attachmentId") Long attachmentId) {
PlatformContractAttachment attachment = attachmentMapper.selectById(attachmentId);
if (attachment == null) {
throw new ResponseStatusException(org.springframework.http.HttpStatus.NOT_FOUND, "附件不存在");
}
String ext = "";
String fileName = attachment.getFileName();
if (fileName != null && fileName.contains(".")) {
ext = fileName.substring(fileName.lastIndexOf('.') + 1).toLowerCase();
}
Map<String, Object> config = new java.util.LinkedHashMap<>();
config.put("document", Map.of(
"fileType", ext,
"key", "attachment_" + attachmentId,
"title", attachment.getFileName(),
"url", getFileUrl(attachmentId),
"permissions", Map.of("download", false, "edit", false, "print", false)
));
config.put("editorConfig", Map.of(
"mode", "view",
"customization", Map.of("autosave", false, "chat", false, "compactHeader", true)
));
config.put("documentServerUrl", onlyofficeUrl);
return ResponseEntity.ok(config);
}
@GetMapping("/{attachmentId}/file")
public ResponseEntity<Resource> getFile(@PathVariable("attachmentId") Long attachmentId) {
PlatformContractAttachment attachment = attachmentMapper.selectById(attachmentId);
if (attachment == null) {
throw new ResponseStatusException(org.springframework.http.HttpStatus.NOT_FOUND, "附件不存在");
}
java.io.File file = new java.io.File(attachment.getFilePath());
if (!file.exists()) {
throw new ResponseStatusException(org.springframework.http.HttpStatus.NOT_FOUND, "文件不存在");
}
FileSystemResource resource = new FileSystemResource(file);
String contentType = attachment.getContentType();
if (contentType == null) contentType = "application/octet-stream";
return ResponseEntity.ok()
.contentType(MediaType.parseMediaType(contentType))
.header(HttpHeaders.CONTENT_DISPOSITION, "inline; filename=\"" + attachment.getFileName() + "\"")
.body(resource);
}
private String getFileUrl(Long attachmentId) {
return "/api/v1/preview/" + attachmentId + "/file";
}
}
@@ -86,9 +86,18 @@
<template #default="{ row }">{{ (row.fileSize / 1024).toFixed(1) }} KB</template> <template #default="{ row }">{{ (row.fileSize / 1024).toFixed(1) }} KB</template>
</el-table-column> </el-table-column>
<el-table-column prop="createdAt" label="上传时间" width="170" /> <el-table-column prop="createdAt" label="上传时间" width="170" />
<el-table-column label="操作" width="80" fixed="right">
<template #default="{ row }">
<el-button type="primary" link @click="previewAttachment(row)">预览</el-button>
</template>
</el-table-column>
</el-table> </el-table>
</div> </div>
<el-dialog v-model="previewDialogVisible" :title="previewTitle" width="90%" top="5vh" destroy-on-close>
<iframe :src="previewUrl" style="width:100%;height:75vh;border:none;border-radius:4px" />
</el-dialog>
<h3 class="section-title">最近审计</h3> <h3 class="section-title">最近审计</h3>
<el-table v-loading="auditLoading" :data="auditRows" border stripe size="small" style="width: 100%"> <el-table v-loading="auditLoading" :data="auditRows" border stripe size="small" style="width: 100%">
<el-table-column label="时间" width="180"> <el-table-column label="时间" width="180">
@@ -169,6 +178,15 @@ const auditRows = ref([]);
const attachments = ref([]); const attachments = ref([]);
const uploading = ref(false); const uploading = ref(false);
const previewDialogVisible = ref(false);
const previewUrl = ref("");
const previewTitle = ref("");
function previewAttachment(row) {
previewTitle.value = row.fileName || "预览";
previewUrl.value = `${window.location.origin}/api/v1/preview/${row.id}`;
previewDialogVisible.value = true;
}
const lineDialogVisible = ref(false); const lineDialogVisible = ref(false);
const lineEditingId = ref(null); const lineEditingId = ref(null);
@@ -307,8 +325,8 @@ function formatAuditTime(row) {
async function loadNameMaps() { async function loadNameMaps() {
try { try {
const [cRes, pRes] = await Promise.all([ const [cRes, pRes] = await Promise.all([
listCustomers({ page: 0, size: 500 }), listCustomers({ page: 0, size: 200 }),
listProjects({ page: 0, size: 500 }), listProjects({ page: 0, size: 200 }),
]); ]);
const cBody = cRes.data && typeof cRes.data === "object" ? cRes.data : {}; const cBody = cRes.data && typeof cRes.data === "object" ? cRes.data : {};
const pBody = pRes.data && typeof pRes.data === "object" ? pRes.data : {}; const pBody = pRes.data && typeof pRes.data === "object" ? pRes.data : {};