Fix PDF generation and report upload: Fix pagination, footer font, backend upload logic, and UI handlers

This commit is contained in:
root 2025-12-09 12:59:04 +08:00
parent bf359d82cd
commit 2772a2a9f2
6 changed files with 369 additions and 66 deletions

View File

@ -1441,7 +1441,56 @@ class AppServer:
except Exception as e:
self.logger.error(f'保存会话信息失败: {e}')
return jsonify({'success': False, 'error': str(e)}), 500
# ==================== 报告上传API ====================
@self.app.route('/api/reports/<session_id>/upload', methods=['POST'])
def upload_report_pdf(session_id):
"""接收前端生成的PDF并保存到文件系统同时更新会话报告路径"""
try:
if not self.db_manager:
return jsonify({'success': False, 'error': '数据库管理器未初始化'}), 500
file = flask_request.files.get('file')
if not file:
return jsonify({'success': False, 'error': '缺少文件'}), 400
# 获取会话信息以得到 patient_id
session_data = self.db_manager.get_session_data(session_id)
if not session_data:
return jsonify({'success': False, 'error': '检测会话不存在'}), 404
patient_id = session_data.get('patient_id')
if not patient_id:
return jsonify({'success': False, 'error': '无法获取患者ID'}), 400
# 选择文件根目录:配置 FILEPATH.path
base_dir_cfg = self.config_manager.get_config_value('FILEPATH', 'path', fallback=None) if self.config_manager else None
if not base_dir_cfg:
return jsonify({'success': False, 'error': '未配置文件存储路径'}), 500
base_dir = os.path.abspath(base_dir_cfg)
timestamp = datetime.now().strftime('%H%M%S%f')[:-3] # 精确到毫秒
# 构建存储路径
base_path = os.path.join(base_dir, str(patient_id), str(session_id))
db_base_path = os.path.join(str(patient_id), str(session_id))
os.makedirs(base_path, exist_ok=True)
filename = f"report_{timestamp}.pdf"
abs_path = os.path.join(base_path, filename)
file.save(abs_path)
# 生成相对路径存入数据库
rel_path = os.path.join(db_base_path, filename).replace('\\', '/')
self.db_manager.update_session_report_path(session_id, rel_path)
return jsonify({'success': True, 'path': rel_path})
except Exception as e:
self.logger.error(f'上传报告失败: {e}')
return jsonify({'success': False, 'error': str(e)}), 500
@self.app.route('/api/detection/<session_id>/save-data', methods=['POST'])
def save_detection_data(session_id):

View File

@ -1,4 +1,4 @@
const { app, BrowserWindow } = require('electron');
const { app, BrowserWindow, ipcMain } = require('electron');
const path = require('path');
const http = require('http');
const fs = require('fs');
@ -8,6 +8,100 @@ let mainWindow;
let localServer;
let backendProcess;
ipcMain.handle('generate-report-pdf', async (event, payload) => {
const win = BrowserWindow.fromWebContents(event.sender);
if (!win) throw new Error('窗口未找到');
// 1. 准备打印环境:克隆节点到独立的打印容器,确保流式布局
await win.webContents.executeJavaScript(`
(function(){
const selector = '${(payload && payload.selector) ? payload.selector : '#report-root'}';
const root = document.querySelector(selector);
if (!root) throw new Error('报告根节点缺失');
// 创建打印容器
const printContainer = document.createElement('div');
printContainer.id = 'electron-print-container';
// 样式设置绝对定位覆盖全屏背景白z-index最高
printContainer.style.position = 'absolute';
printContainer.style.top = '0';
printContainer.style.left = '0';
printContainer.style.width = '100%';
printContainer.style.minHeight = '100vh';
printContainer.style.background = '#ffffff';
printContainer.style.zIndex = '2147483647';
printContainer.style.display = 'block';
printContainer.style.overflow = 'visible'; // 关键:允许内容溢出以触发分页
// 克隆目标节点
const cloned = root.cloneNode(true);
// 强制重置克隆节点的关键样式,确保它是流式布局
cloned.style.position = 'static'; // 必须是 static 或 relative
cloned.style.display = 'block';
cloned.style.width = '100%';
cloned.style.height = 'auto';
cloned.style.overflow = 'visible';
cloned.style.margin = '0';
cloned.style.transform = 'none'; // 移除可能影响打印的变换
// 将克隆节点加入容器
printContainer.appendChild(cloned);
// 将容器加入 body
document.body.appendChild(printContainer);
// 关键修复:打印时只保留我们的打印容器可见,其他所有元素隐藏
// 我们创建一个 style 标签来强制隐藏除了 printContainer 以外的所有 body 直接子元素
const style = document.createElement('style');
style.id = 'print-style-override';
style.innerHTML = \`
@media print {
body > *:not(#electron-print-container) {
display: none !important;
}
#electron-print-container {
display: block !important;
}
}
\`;
document.head.appendChild(style);
document.body.classList.add('print-mode');
return true;
})()
`);
try {
const pdf = await win.webContents.printToPDF({
pageSize: (payload && payload.pageSize) ? payload.pageSize : 'A4',
landscape: !!(payload && payload.landscape),
printBackground: !(payload && payload.printBackground === false),
marginsType: 0,
displayHeaderFooter: true, // 启用页眉页脚
headerTemplate: '<div></div>', // 空页眉
footerTemplate: `
<div style="width: 100%; text-align: center; font-size: 10px; font-family: 'Microsoft YaHei', sans-serif; padding-top: 5px; padding-bottom: 5px;">
<span class="pageNumber"></span> 页 / <span class="totalPages"></span>
</div>
`
});
return pdf;
} finally {
// 3. 清理环境
await win.webContents.executeJavaScript(`
(function(){
const container = document.getElementById('electron-print-container');
if (container) container.remove();
document.body.classList.remove('print-mode');
return true;
})()
`);
}
});
function startBackendService() {
// 在打包后的应用中使用process.resourcesPath获取resources目录
const resourcesPath = process.resourcesPath || path.join(__dirname, '../..');
@ -98,6 +192,7 @@ function createWindow() {
webPreferences: {
nodeIntegration: false,
contextIsolation: true,
sandbox: false, // 显式关闭沙盒,避免 preload 加载问题
preload: path.join(__dirname, 'preload.js')
},
icon: path.join(__dirname, '../public/logo.png'),

View File

@ -1,2 +1,6 @@
const { contextBridge } = require('electron');
const { contextBridge, ipcRenderer } = require('electron');
contextBridge.exposeInMainWorld('electronAPI', {
generateReportPdf: (payload) => ipcRenderer.invoke('generate-report-pdf', payload),
getBackendUrl: () => 'http://localhost:5000'
});

View File

@ -20,9 +20,10 @@
"echarts": "^5.4.3",
"element-plus": "^2.3.9",
"html2canvas": "^1.4.1",
"three": "^0.160.0",
"jspdf": "^3.0.4",
"pinia": "^2.1.6",
"socket.io-client": "^4.7.2",
"three": "^0.160.0",
"vue": "^3.3.4",
"vue-echarts": "^6.6.1",
"vue-router": "^4.2.4"
@ -35,5 +36,5 @@
"electron-packager": "^17.1.2",
"vite": "^4.4.9",
"wait-on": "^7.0.1"
}
}
}

View File

@ -4,7 +4,7 @@
<div class="generateReport-container-headertitle">生成报告</div>
<div style="display: flex;">
<div class="generateReport-container-cancelbutton" @click="closeCancel">取消</div>
<div class="generateReport-container-confirmbutton" @click="confirmCancel">确定</div>
<div class="generateReport-container-confirmbutton" @click="confirmGenerateReport">确定</div>
</div>
<img src="@/assets/archive/close.png" alt="" @click="closeCancel" style="cursor: pointer;">
</div>
@ -295,7 +295,7 @@ onMounted(() => {
function closeCancel() {
emit("closeGenerateReport",false)
}
function confirmCancel() {
function confirmGenerateReport() {
if(rawData.value.id == null){
ElMessage.error('请选择原始数据')
return

View File

@ -1,5 +1,5 @@
<template>
<div class="PopUpReport-container">
<div class="PopUpReport-container" id="popup-report-root">
<div class="PopUpReport-container-body" id="pdf-content">
<div style="height: 100%; padding:0 90px; box-sizing: border-box;">
<div class="PopUpReport-container-bodytitle">体态测量报告单</div>
@ -195,8 +195,8 @@
<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
import { getBackendUrl } from '@/services/api.js'
import html2canvas from 'html2canvas';
import jsPDF from 'jspdf';
import { ElMessage } from 'element-plus'
const emit = defineEmits([ 'closePopUpReport' ]);
const props = defineProps({
selectedPatient: {
@ -272,65 +272,65 @@ onUnmounted(() => {
})
const generatePDF = async () => {
const element = document.getElementById('pdf-content');
// PDF
const pdf = new jsPDF('p', 'mm', 'a4');
const pageWidth = pdf.internal.pageSize.getWidth();
const pageHeight = pdf.internal.pageSize.getHeight();
//
const scale = 2; //
const elementWidth = element.offsetWidth;
const elementHeight = element.scrollHeight;
const widthRatio = pageWidth / (elementWidth / scale);
//
const pageContentHeight = (pageHeight / widthRatio) * scale;
let position = 0;
let currentPage = 1;
while (position < elementHeight) {
//
if (currentPage > 1) {
pdf.addPage();
try {
const root = document.getElementById('popup-report-root')
if (!root) {
ElMessage.error('报告容器未找到')
return
}
//
const canvas = await html2canvas(element, {
scale,
useCORS: true,
windowHeight: pageContentHeight,
height: pageContentHeight,
y: position,
x: 0,
scrollY: -window.scrollY //
});
//
const imgData = canvas.toDataURL('image/jpeg', 1.0);
const imgWidth = pageWidth;
const imgHeight = (canvas.height * imgWidth) / canvas.width;
if (!window.electronAPI) {
console.error('electronAPI 未定义')
return
}
const pdfBuffer = await window.electronAPI.generateReportPdf({
selector: '#popup-report-root',
pageSize: 'A4',
landscape: true, //
printBackground: true
})
// PDF
pdf.addImage(imgData, 'JPEG', 0, 0, imgWidth, imgHeight);
const blob = new Blob([pdfBuffer], { type: 'application/pdf' })
const form = new FormData()
// 使ID
const filename = `${props.detectionInfo.id || 'report'}.pdf`
form.append('file', blob, filename)
//
position += pageContentHeight;
currentPage++;
// detectionInfo.id
if (props.detectionInfo.id) {
const res = await fetch(`${BACKEND_URL}/api/reports/${props.detectionInfo.id}/upload`, {
method: 'POST',
body: form
})
if (!res.ok) {
throw new Error(`HTTP ${res.status}: ${res.statusText}`)
}
const json = await res.json()
if (json.success) {
ElMessage.success('报告生成并上传成功')
emit('closePopUpReport', true)
} else {
throw new Error(json.error || '上传失败')
}
} else {
//
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filename
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
emit('closePopUpReport', true)
}
} catch (e) {
console.error('报告生成异常:', e)
ElMessage.error(`报告生成失败:${e.message}`)
}
// PDF
const pdfBlob = pdf.output('blob');
const url = URL.createObjectURL(pdfBlob);
const a = document.createElement('a');
a.href = url;
a.download = 'document.pdf';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
emit("closePopUpReport",false)
};
</script>
@ -350,7 +350,7 @@ const generatePDF = async () => {
width: 1600px;
background: #fff;
border-radius: 5px;
padding: 50px 0 20px;
padding: 20px 0 30px;
}
@ -520,4 +520,158 @@ const generatePDF = async () => {
color: #383838;
padding: 5px;
}
</style>
</style>
<style>
@media print {
@page {
size: A4 landscape;
margin: 10mm;
}
html, body {
height: auto !important;
overflow: visible !important;
background: white !important;
}
/* 隐藏所有元素,但保留占位,避免 display:none 导致的父级隐藏问题 */
body * {
visibility: hidden;
}
/* 显式显示目标容器及其子元素 */
#popup-report-root,
#popup-report-root *,
#electron-print-container,
#electron-print-container * {
visibility: visible !important;
}
/* 确保根节点可见并重置布局 */
#popup-report-root {
position: static !important;
width: 100% !important;
height: auto !important;
margin: 0 !important;
padding: 0 !important;
background-color: white !important;
z-index: 9999 !important;
overflow: visible !important;
}
/* 容器样式重置 */
.PopUpReport-container-body {
width: 100% !important;
margin: 0 !important;
box-shadow: none !important;
padding: 10px 10px 30px 10px !important; /* 底部增加padding给页码留空间 */
border: none !important;
height: auto !important;
overflow: visible !important;
display: block !important;
min-height: 100vh !important;
}
/* 移除内层div的固定高度 */
.PopUpReport-container-body > div {
height: auto !important;
padding: 0 !important;
}
/* 针对克隆节点的特定样式 */
#electron-print-container .PopUpReport-container {
position: static !important;
width: 100% !important;
height: auto !important;
overflow: visible !important;
margin: 0 !important;
padding: 0 !important;
background-color: white !important;
}
/* 左右分栏调整 */
.PopUpReport-container-leftbox,
.PopUpReport-container-rightbox {
width: 49% !important;
display: inline-block !important;
vertical-align: top !important;
box-sizing: border-box !important;
margin: 0 !important;
padding: 0 10px !important;
border: none !important;
/* 允许分栏内部内容自然分页 */
break-inside: auto !important;
}
.PopUpReport-container-leftbox {
border-right: 1px solid #ccc !important;
}
/* 图片容器自适应 */
.PopUpReport-container-body div[style*="width: 600px"] {
width: 100% !important;
height: auto !important;
margin-bottom: 10px !important;
break-inside: avoid !important;
}
/* 图片自适应 */
img {
max-width: 100% !important;
height: auto !important;
object-fit: contain !important;
max-height: 300px !important; /* 限制最大高度,防止一页占满 */
}
/* 头部信息紧凑化 */
.PopUpReport-container-bodytitle {
font-size: 24px !important;
padding-bottom: 10px !important;
}
.PopUpReport-container-display,
.PopUpReport-container-userinfodisplay {
padding: 10px 0 !important;
}
.PopUpReport-container-userinfotext2 {
font-size: 14px !important;
width: auto !important;
margin-right: 20px !important;
padding-bottom: 5px !important;
}
/* 标题字号调整 */
.PopUpReport-content-title,
.PopUpReport-container-testdatatitle,
.PopUpReport-title2 {
font-size: 16px !important;
padding-top: 10px !important;
padding-bottom: 5px !important;
}
/* 分页控制 */
.PopUpReport-content-title {
break-after: avoid;
}
img {
break-inside: avoid;
}
/* 隐藏不需要的UI元素 */
.displayflexselect-icon,
::-webkit-scrollbar {
display: none !important;
}
/* 页脚页码设置 */
@bottom-right {
content: "第 " counter(page) " 页 共 " counter(pages) " 页";
font-size: 12px;
font-family: Arial, sans-serif;
color: #666;
}
}
</style>