新增PrintDot桥接功能,支持本地打印机连接和配置,优化打印模板设计,允许多页表格重复显示,改进打印预览和设计器界面,确保用户体验流畅。

This commit is contained in:
geht
2026-04-17 19:00:30 +08:00
parent 2bd4c5584d
commit efb6a9f838
14 changed files with 1196 additions and 110 deletions

View File

@@ -95,6 +95,17 @@
打印
</a-button>
</a-tooltip>
<a-tooltip title="经 PrintDot 本地客户端以 PDF 静默打印(需本机桥接器运行)">
<a-button
type="default"
size="small"
class="preview-print-btn"
:loading="printDotLoading"
@click="handlePrintDotPrint"
>
PrintDot
</a-button>
</a-tooltip>
</a-space>
</div>
<div v-if="layoutPaperPx" ref="previewHostRef" class="preview-frame-wrap">
@@ -152,6 +163,7 @@
import { generateNativeMockDataObject } from '../native/core/nativeMockData';
import { normalizeImportedNativeSchema } from '../native/core/nativeSchemaNormalize';
import type { NativeTemplateSchema } from '../native/core/types';
import { printNativeSchemaViaPrintDot } from '../utils/printNativeViaPrintDot';
const props = defineProps<{
open: boolean;
@@ -171,6 +183,7 @@
});
const loading = ref(false);
const printDotLoading = ref(false);
const errorText = ref('');
const schema = ref<NativeTemplateSchema | null>(null);
const canvasJsonText = ref('{}');
@@ -372,6 +385,26 @@
window.setTimeout(() => updateContentMeasure(), 400);
}
async function handlePrintDotPrint() {
if (!schema.value) {
createMessage.warning('模板未加载');
return;
}
printDotLoading.value = true;
try {
await printNativeSchemaViaPrintDot({
schema: schema.value,
data: previewData.value,
jobName: props.templateId ? `tpl-${props.templateId}` : 'native-preview',
});
createMessage.success('已通过 PrintDot 提交打印');
} catch (e: any) {
createMessage.error(e?.message || 'PrintDot 打印失败');
} finally {
printDotLoading.value = false;
}
}
/** 调用浏览器打印预览 iframe 内文档(与当前预览 HTML 一致) */
function handleBrowserPrint() {
const win = previewIframeRef.value?.contentWindow;

View File

@@ -20,6 +20,20 @@
/>
<a-button @click="addManualPrinter">添加打印机</a-button>
<a-button @click="refreshPrinterOptions">刷新打印机</a-button>
<a-checkbox v-model:checked="printDotEnabled" @change="onPrintDotEnabledChange">PrintDot 桥接</a-checkbox>
<a-input
v-model:value="printDotWsUrl"
style="width: 200px"
placeholder="WS 地址"
@blur="persistPrintDotConfig"
/>
<a-input-password
v-model:value="printDotKey"
style="width: 120px"
placeholder="密钥(可选)"
autocomplete="new-password"
@blur="persistPrintDotConfig"
/>
</a-space>
<a-button type="primary" ghost @click="handleCreateNative" v-auth="'print:template:add'">新增原生模板</a-button>
<a-button type="primary" @click="openQuickPrintModal" v-auth="'print:template:list'">快速打印</a-button>
@@ -50,8 +64,13 @@
<a-radio-button value="templateStyle">按模板样式打印推荐</a-radio-button>
<a-radio-button value="lodopTemplate">Lodop实验模板样式</a-radio-button>
<a-radio-button value="pdfServer">前端转PDF后端打印</a-radio-button>
<a-radio-button value="printDotBridge">PrintDot 本地桥接PDF</a-radio-button>
<a-radio-button value="serverText">服务端直打纯文本</a-radio-button>
</a-radio-group>
<div v-if="quickPrintMode === 'printDotBridge'" style="font-size: 12px; color: rgba(0, 0, 0, 0.55)">
需本机运行 PrintDot 客户端默认 WebSocket ws://127.0.0.1:1122/ws勾选PrintDot
桥接并刷新打印机后可选择桥接器上报的打印机支持原生模板与 hiprint 模板均在前端转 PDF 后送出
</div>
<a-space style="width: 100%">
<a-select
v-model:value="quickPrintForm.templateCode"
@@ -180,6 +199,19 @@
import PrintTemplateModal from './components/PrintTemplateModal.vue';
import NativeTemplateListPreviewModal from './components/NativeTemplateListPreviewModal.vue';
import { QUICK_PRINT_PREVIEW_STORAGE_KEY } from './quickPrintPreviewStorage';
import {
buildPdfBase64FromHtmlFragment,
extractBodyInnerHtmlFromFullDocument,
} from './utils/printHtmlToPdfBase64';
import {
fetchPrintDotPrinters,
getPrintDotBridgeConfig,
printDotSendPdf,
resolvePrintDotPrinterName,
setPrintDotBridgeConfig,
} from './utils/printDotBridge';
import { normalizeImportedNativeSchema } from './native/core/nativeSchemaNormalize';
import { renderNativePrintHtml } from './native/core/printRenderer';
defineOptions({ name: 'PrintTemplateList' });
@@ -202,7 +234,23 @@
printerName: '__system_default__',
dataJson: '',
});
const quickPrintMode = ref<'templateStyle' | 'lodopTemplate' | 'pdfServer' | 'serverText'>('templateStyle');
const quickPrintMode = ref<'templateStyle' | 'lodopTemplate' | 'pdfServer' | 'printDotBridge' | 'serverText'>(
'templateStyle',
);
const LS_PRINT_DOT_ENABLED = 'qhmes_print_dot_enabled';
const printDotEnabled = ref(localStorage.getItem(LS_PRINT_DOT_ENABLED) === '1');
const printDotCfg = getPrintDotBridgeConfig();
const printDotWsUrl = ref(printDotCfg.wsUrl);
const printDotKey = ref(printDotCfg.key);
function persistPrintDotConfig() {
setPrintDotBridgeConfig(printDotWsUrl.value, printDotKey.value);
}
function onPrintDotEnabledChange() {
localStorage.setItem(LS_PRINT_DOT_ENABLED, printDotEnabled.value ? '1' : '0');
void refreshPrinterOptions(false);
}
/** 技能转换打印:示例数据(与快速打印占位说明一致) */
const SKILL_DATA_JSON_EXAMPLE = `{
"docNo": "MO-001",
@@ -311,10 +359,31 @@
});
}
printerOptions.value = Array.from(optionMap.values());
if (printDotEnabled.value) {
try {
const dotList = await fetchPrintDotPrinters();
dotList.forEach((p) => {
const name = String(p.name || '').trim();
if (!name) {
return;
}
if (!optionMap.has(name)) {
optionMap.set(name, { label: `[PrintDot] ${name}`, value: name });
}
});
if (showMessage && dotList.length) {
createMessage.success(`PrintDot 已连接,识别 ${dotList.length} 台打印机`);
}
} catch (e: any) {
if (showMessage) {
createMessage.warning(`PrintDot${e?.message || '无法连接本地桥接器'}`);
}
}
}
if (showMessage) {
if (names.length) {
createMessage.success(`已从服务端识别到 ${names.length} 台打印机`);
} else {
} else if (!printDotEnabled.value) {
const reason = String(payload?.capability?.localReason || '').trim();
createMessage.warning(`服务端未返回可用打印机。${reason || '请在后端配置网络打印机后重试。'}`);
}
@@ -510,7 +579,7 @@
const panel = Array.isArray(templateJson?.panels) && templateJson.panels.length ? templateJson.panels[0] : {};
const widthMm = Number(panel?.width || 210);
const heightMm = Number(panel?.height || 297);
const pdfBase64 = await buildPdfBase64FromTemplate(html, widthMm, heightMm);
const pdfBase64 = await buildPdfBase64FromHtmlFragment(html, widthMm, heightMm);
const printer = String(params.printerName || '').trim();
await directPrintPdf({
templateCode,
@@ -1249,54 +1318,6 @@
</html>`;
}
function mmToPx(mm: number) {
return (mm * 96) / 25.4;
}
function arrayBufferToBase64(buffer: ArrayBuffer): string {
const bytes = new Uint8Array(buffer);
const chunkSize = 0x8000;
let binary = '';
for (let i = 0; i < bytes.length; i += chunkSize) {
const chunk = bytes.subarray(i, i + chunkSize);
binary += String.fromCharCode(...chunk);
}
return btoa(binary);
}
async function buildPdfBase64FromTemplate(html: string, widthMm: number, heightMm: number): Promise<string> {
const [{ jsPDF }, html2canvasModule] = await Promise.all([import('jspdf'), import('html2canvas')]);
const html2canvas = html2canvasModule.default;
const container = document.createElement('div');
container.style.position = 'fixed';
container.style.left = '-20000px';
container.style.top = '0';
container.style.width = `${Math.max(1, mmToPx(widthMm))}px`;
container.style.background = '#fff';
container.style.zIndex = '-1';
container.innerHTML = `<div class="lodop-print-root" style="width:${widthMm}mm;margin:0;padding:0;background:#fff;">${html}</div>`;
document.body.appendChild(container);
try {
const target = (container.querySelector('.lodop-print-root') || container) as HTMLElement;
await new Promise((resolve) => setTimeout(resolve, 80));
const canvas = await html2canvas(target, {
backgroundColor: '#ffffff',
scale: 2,
useCORS: true,
allowTaint: true,
logging: false,
});
const orientation = widthMm > heightMm ? 'landscape' : 'portrait';
const pdf = new jsPDF({ orientation, unit: 'mm', format: [widthMm, heightMm] });
const imgData = canvas.toDataURL('image/jpeg', 0.95);
pdf.addImage(imgData, 'JPEG', 0, 0, widthMm, heightMm);
const buffer = pdf.output('arraybuffer');
return arrayBufferToBase64(buffer);
} finally {
container.remove();
}
}
async function handleQuickPrint() {
const templateCode = String(quickPrintForm.value.templateCode || '').trim();
if (!templateCode) {
@@ -1325,6 +1346,70 @@
dataJson,
});
createMessage.success('已提交服务端直打任务');
} else if (quickPrintMode.value === 'printDotBridge') {
const tplData = (await queryByCode(templateCode)) as Record<string, any>;
const templateJsonText = String(tplData?.templateJson || '').trim();
if (!templateJsonText) {
createMessage.error('模板JSON为空');
return;
}
let templateJson: any;
try {
templateJson = JSON.parse(templateJsonText);
} catch (error: any) {
createMessage.error(`模板JSON格式错误${error?.message || '未知错误'}`);
return;
}
let pdfBase64: string;
if (templateJson?.engine === 'native') {
const normalized = normalizeImportedNativeSchema(templateJson);
const pw = Number(tplData?.paperWidthMm);
const ph = Number(tplData?.paperHeightMm);
if (pw > 0) {
normalized.page.width = pw;
}
if (ph > 0) {
normalized.page.height = ph;
}
const fullHtml = await renderNativePrintHtml(normalized, dataJson);
const inner = extractBodyInnerHtmlFromFullDocument(fullHtml);
pdfBase64 = await buildPdfBase64FromHtmlFragment(
inner,
normalized.page.width,
normalized.page.height,
{ paginate: true },
);
} else {
await initHiprintForQuickPrint();
const runtimeTemplate = new hiprint.PrintTemplate({
template: templateJson,
});
const html = optimizeMergedHeaderHtml(await resolveTemplateHtml(runtimeTemplate, dataJson), templateJson);
if (!html) {
createMessage.error('未能生成预览 HTML无法转 PDF');
return;
}
const panel = Array.isArray(templateJson?.panels) && templateJson.panels.length ? templateJson.panels[0] : {};
const widthMm = Number(panel?.width || 210);
const heightMm = Number(panel?.height || 297);
pdfBase64 = await buildPdfBase64FromHtmlFragment(html, widthMm, heightMm);
}
const dotPrinters = await fetchPrintDotPrinters();
const resolvedPrinter = resolvePrintDotPrinterName(printer, dotPrinters);
if (!resolvedPrinter) {
createMessage.error('PrintDot请选择具体打印机或确认桥接器已返回打印机列表');
return;
}
const dotResult = await printDotSendPdf({
printer: resolvedPrinter,
pdfBase64,
jobName: templateCode,
timeoutMs: 180000,
});
if (!dotResult.ok) {
throw new Error(dotResult.message || 'PrintDot 打印失败');
}
createMessage.success('已通过 PrintDot 提交打印');
} else {
await initHiprintForQuickPrint();
if (quickPrintMode.value === 'pdfServer') {

View File

@@ -13,6 +13,7 @@
<a-button @click="sendBackward">下移图层</a-button>
<a-button @click="previewTemplate">即时预览</a-button>
<a-button @click="printTemplate">打印</a-button>
<a-button :loading="printDotLoading" @click="printTemplateViaPrintDot">PrintDot 打印</a-button>
<a-button type="primary" :loading="saving" @click="saveTemplate">保存模板</a-button>
</a-space>
</div>
@@ -135,8 +136,21 @@
<PageConfigModal v-model:open="pageConfigModalOpen" :schema="state.schema" @update-page="updatePage" />
<a-drawer v-model:open="previewVisible" title="即时预览" width="70%" placement="right">
<iframe class="preview-frame" :srcdoc="previewHtml"></iframe>
<a-drawer
v-model:open="previewVisible"
title="即时预览"
width="70%"
placement="right"
:body-style="{ padding: '12px 16px 20px', overflow: 'hidden' }"
>
<div class="native-instant-preview-host">
<iframe
class="preview-frame"
title="原生模板即时预览"
:srcdoc="previewHtml"
:style="{ height: `${previewDrawerIframeHeightPx}px` }"
/>
</div>
</a-drawer>
<FreeTableCellEditModal
@@ -293,6 +307,7 @@
import PropertiesPanel from './components/PropertiesPanel.vue';
import ToolbarPalette from './components/ToolbarPalette.vue';
import { printHtml } from './core/printService';
import { printNativeSchemaViaPrintDot } from '../utils/printNativeViaPrintDot';
import { renderNativePrintHtml, resolvePrintPageCount } from './core/printRenderer';
import { generateNativeMockDataObject } from './core/nativeMockData';
import { buildNativeTemplateStylePayload } from './core/nativeTemplateStyleSerialize';
@@ -328,6 +343,7 @@
} = useDesignerStore();
const templateId = ref('');
const saving = ref(false);
const printDotLoading = ref(false);
const previewVisible = ref(false);
const previewHtml = ref('');
const selectedTableColumn = ref<{ elementId: string; columnKey: string } | null>(null);
@@ -796,6 +812,20 @@
}
});
/** 与列表预览逻辑一致:按 resolvePrintPageCount 估算 iframe 高度,避免多页内容被单页裁切 */
const PREVIEW_MM_TO_CSS_PX = 96 / 25.4;
const previewDrawerIframeHeightPx = computed(() => {
const sch = state?.schema;
const hMm = Number(sch?.page?.heightMm);
if (!sch?.page || !Number.isFinite(hMm) || hMm <= 0) {
return 900;
}
const count = Math.max(1, resolvePrintPageCount(sch, previewData.value));
const gapPx = 12;
const padY = 24 * 2;
return Math.round(count * hMm * PREVIEW_MM_TO_CSS_PX + (count - 1) * gapPx + padY);
});
function generateMockData(options: { syncManual?: boolean; showMessage?: boolean } = {}) {
const { syncManual = false, showMessage = true } = options;
const mock = generateNativeMockDataObject(state.schema.elements, canvasJsonText.value);
@@ -921,6 +951,7 @@
if (payload?.innerBorder && typeof payload.innerBorder === 'object') {
base.innerBorder = { ...payload.innerBorder };
}
base.printRepeated = payload.printRepeated === true;
base.cells = Array.isArray(payload?.cells)
? payload.cells
.map((cell: any) => ({
@@ -1295,6 +1326,22 @@
printHtml(html);
}
async function printTemplateViaPrintDot() {
printDotLoading.value = true;
try {
await printNativeSchemaViaPrintDot({
schema: state.schema,
data: previewData.value,
jobName: String(meta.templateCode || '').trim() || 'native-print',
});
createMessage.success('已通过 PrintDot 提交打印');
} catch (error: any) {
createMessage.error(error?.message || 'PrintDot 打印失败');
} finally {
printDotLoading.value = false;
}
}
function handleDelete() {
removeSelected();
selectedTableColumn.value = null;
@@ -1976,10 +2023,23 @@
border-left: 1px solid #f0f0f0;
}
/* 高度由 :style 绑定 previewDrawerIframeHeightPx避免与多页内容冲突 */
.native-instant-preview-host {
max-height: calc(100vh - 120px);
overflow: auto;
padding: 12px;
box-sizing: border-box;
background: #f0f0f0;
border-radius: 6px;
}
.preview-frame {
display: block;
width: 100%;
height: calc(100vh - 120px);
min-height: 320px;
border: 1px solid #f0f0f0;
background: #fff;
box-sizing: border-box;
}
.image-analyze-modal-title {

View File

@@ -24,7 +24,9 @@
</div>
</div>
<div class="ruler-corner"></div>
<div class="designer-canvas" :style="canvasStyle" @click="emit('select', '')">
<div class="designer-canvas" :style="canvasPaperStyle" @click="emit('select', '')">
<!-- 版心纸张尺寸减去页边距白底+网格外围为边距区 -->
<div class="canvas-print-area" :style="printAreaStyle" />
<div v-if="bandLayout.headerHeight > 0" class="band-area band-header" :style="{ height: `${bandLayout.headerHeight}mm` }">报表头区域</div>
<div
v-if="bandLayout.footerHeight > 0"
@@ -43,8 +45,24 @@
:style="{ top: `${bandLayout.bodyBottom}mm` }"
v-if="bandLayout.footerHeight > 0"
></div>
<div v-if="guideState.showVertical" class="center-guide vertical" :style="{ left: `${pageCenterXPx}mm` }"></div>
<div v-if="guideState.showHorizontal" class="center-guide horizontal" :style="{ top: `${pageCenterYPx}mm` }"></div>
<div
v-if="guideState.showVertical"
class="center-guide vertical"
:style="{
left: `${pageContentCenter.x}mm`,
top: `${pageMargins.top}mm`,
height: `${innerPageSize.height}mm`,
}"
></div>
<div
v-if="guideState.showHorizontal"
class="center-guide horizontal"
:style="{
top: `${pageContentCenter.y}mm`,
left: `${pageMargins.left}mm`,
width: `${innerPageSize.width}mm`,
}"
></div>
<ElementWrapper
v-for="element in sortedElements"
:key="element.id"
@@ -57,6 +75,7 @@
:movable="!isBandElement(element)"
:resizable="!isBandElement(element)"
:drag-bounds="resolveDragBounds(element)"
:page-margins="pageMargins"
@select="emit('select', $event)"
@update="emit('update', $event)"
@dragging="handleElementDragging"
@@ -160,19 +179,71 @@
height: `${props.schema.page.height * PX_PER_MM * props.scale + RULER_SIZE}px`,
}));
const canvasStyle = computed(() => ({
const pageMargins = computed(() => {
const m = props.schema.page.margin;
if (!Array.isArray(m) || m.length < 4) {
return { top: 0, right: 0, bottom: 0, left: 0 };
}
return {
top: Math.max(0, Number(m[0] || 0)),
right: Math.max(0, Number(m[1] || 0)),
bottom: Math.max(0, Number(m[2] || 0)),
left: Math.max(0, Number(m[3] || 0)),
};
});
/** 版心宽高mm */
const innerPageSize = computed(() => {
const W = props.schema.page.width;
const H = props.schema.page.height;
const mar = pageMargins.value;
return {
width: Math.max(10, W - mar.left - mar.right),
height: Math.max(10, H - mar.top - mar.bottom),
};
});
/** 版心中心点(仍用纸张绝对坐标,便于与元素 x/y 一致) */
const pageContentCenter = computed(() => {
const mar = pageMargins.value;
const ip = innerPageSize.value;
return { x: mar.left + ip.width / 2, y: mar.top + ip.height / 2 };
});
/** 整张纸:边距外浅灰,不含网格 */
const canvasPaperStyle = computed(() => ({
left: `${RULER_SIZE}px`,
top: `${RULER_SIZE}px`,
width: `${props.schema.page.width}mm`,
height: `${props.schema.page.height}mm`,
transform: `scale(${props.scale})`,
transformOrigin: 'top left',
backgroundSize: `${props.schema.page.gridSize}mm ${props.schema.page.gridSize}mm`,
backgroundImage:
'linear-gradient(to right, rgba(22,119,255,0.08) 1px, transparent 1px),linear-gradient(to bottom, rgba(22,119,255,0.08) 1px, transparent 1px)',
position: 'absolute' as const,
margin: 0,
background: '#dadde3',
boxShadow: '0 2px 12px rgba(0, 0, 0, 0.12)',
}));
const pageCenterXPx = computed(() => props.schema.page.width / 2);
const pageCenterYPx = computed(() => props.schema.page.height / 2);
/** 可编辑版心区域 */
const printAreaStyle = computed(() => {
const mar = pageMargins.value;
const ip = innerPageSize.value;
const g = props.schema.page.gridSize;
return {
position: 'absolute' as const,
left: `${mar.left}mm`,
top: `${mar.top}mm`,
width: `${ip.width}mm`,
height: `${ip.height}mm`,
backgroundColor: '#fff',
backgroundSize: `${g}mm ${g}mm`,
backgroundImage:
'linear-gradient(to right, rgba(22,119,255,0.08) 1px, transparent 1px),linear-gradient(to bottom, rgba(22,119,255,0.08) 1px, transparent 1px)',
pointerEvents: 'none' as const,
zIndex: 0,
boxSizing: 'border-box' as const,
};
});
const topTicks = computed(() => buildRulerTicks(props.schema.page.width));
const leftTicks = computed(() => buildRulerTicks(props.schema.page.height));
@@ -317,8 +388,8 @@
}
const centerX = payload.rect.x + payload.rect.w / 2;
const centerY = payload.rect.y + payload.rect.h / 2;
const pageCenterX = props.schema.page.width / 2;
const pageCenterY = props.schema.page.height / 2;
const pageCenterX = pageContentCenter.value.x;
const pageCenterY = pageContentCenter.value.y;
const thresholdMm = Math.max(0.5, 1 / Math.max(0.2, props.scale));
guideState.showVertical = Math.abs(centerX - pageCenterX) <= thresholdMm;
guideState.showHorizontal = Math.abs(centerY - pageCenterY) <= thresholdMm;
@@ -338,6 +409,11 @@
function resolveDragBounds(element: NativeElement) {
const pageWidth = props.schema.page.width;
const pageHeight = props.schema.page.height;
const mar = pageMargins.value;
const ml = mar.left;
const mr = mar.right;
const mt = mar.top;
const mb = mar.bottom;
if (isBandElement(element)) {
const isHeader = element.type === 'reportHeader';
const fixedY = isHeader ? 0 : Math.max(0, props.schema.page.height - element.h);
@@ -349,29 +425,32 @@
};
}
const region = resolveElementRegion(element);
const minX = 0;
const maxX = Math.max(0, pageWidth - element.w);
const minX = ml;
const maxX = Math.max(ml, pageWidth - mr - element.w);
if (region === 'header') {
return {
minX,
maxX,
minY: 0,
maxY: Math.max(0, bandLayout.value.headerHeight - element.h),
minY: mt,
maxY: Math.max(mt, bandLayout.value.headerHeight - element.h),
};
}
if (region === 'footer') {
return {
minX,
maxX,
minY: Math.max(0, bandLayout.value.bodyBottom),
maxY: Math.max(0, pageHeight - element.h),
minY: Math.max(bandLayout.value.bodyBottom, mt),
maxY: Math.max(mt, pageHeight - mb - element.h),
};
}
return {
minX,
maxX,
minY: bandLayout.value.headerHeight,
maxY: Math.max(bandLayout.value.headerHeight, bandLayout.value.bodyBottom - element.h),
minY: Math.max(bandLayout.value.headerHeight, mt),
maxY: Math.max(
bandLayout.value.headerHeight,
Math.min(bandLayout.value.bodyBottom - element.h, pageHeight - mb - element.h),
),
};
}
</script>
@@ -468,8 +547,10 @@
.designer-canvas {
position: absolute;
margin: 0;
background: #fff;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.12);
}
.canvas-print-area {
border-radius: 0;
}
.band-area {

View File

@@ -9,7 +9,7 @@
<script lang="ts" setup>
import { computed } from 'vue';
import { calcDragRect, calcResizeRect } from '../core/dragResize';
import { calcDragRect, calcResizeRect, type PageMarginsMm } from '../core/dragResize';
import type { NativeElement } from '../core/types';
const PX_PER_MM = 3.7795275591;
@@ -28,6 +28,8 @@
minY: number;
maxY: number;
};
/** 页面边距mm拖拽/缩放时限制在版心内,与画布展示一致 */
pageMargins?: PageMarginsMm;
}>();
const emit = defineEmits<{
@@ -38,6 +40,8 @@
const handles = ['nw', 'n', 'ne', 'e', 'se', 's', 'sw', 'w'];
const marginsMm = computed<PageMarginsMm>(() => props.pageMargins || { top: 0, right: 0, bottom: 0, left: 0 });
const wrapperStyle = computed(() => ({
position: 'absolute',
left: `${props.element.x}mm`,
@@ -72,7 +76,14 @@
// 鼠标位移是 px画布坐标是 mm这里必须做单位换算才会跟手
const deltaX = (moveEvent.clientX - startX) / props.scale / PX_PER_MM;
const deltaY = (moveEvent.clientY - startY) / props.scale / PX_PER_MM;
const next = calcDragRect(start, { width: props.pageWidth, height: props.pageHeight }, deltaX, deltaY, props.gridSize);
const next = calcDragRect(
start,
{ width: props.pageWidth, height: props.pageHeight },
deltaX,
deltaY,
props.gridSize,
marginsMm.value,
);
const bounded = clampByBounds(next);
emit('update', { id: props.element.id, patch: bounded });
emit('dragging', { id: props.element.id, rect: bounded, active: true });
@@ -96,7 +107,15 @@
// 鼠标位移是 px画布尺寸是 mm缩放时同样要按单位换算
const deltaX = (moveEvent.clientX - startX) / props.scale / PX_PER_MM;
const deltaY = (moveEvent.clientY - startY) / props.scale / PX_PER_MM;
const next = calcResizeRect(direction, start, { width: props.pageWidth, height: props.pageHeight }, deltaX, deltaY, props.gridSize);
const next = calcResizeRect(
direction,
start,
{ width: props.pageWidth, height: props.pageHeight },
deltaX,
deltaY,
props.gridSize,
marginsMm.value,
);
emit('update', { id: props.element.id, patch: next });
};
const onUp = () => {

View File

@@ -609,6 +609,13 @@
<div class="free-table-track-head">单元格合并</div>
<a-button type="primary" size="small" block :disabled="!canMergeFreeTableSelection" @click="mergeFreeTableSelection">合并选中区域</a-button>
<a-button size="small" block :disabled="!canSplitFreeTableMerged" @click="splitFreeTableMerged">拆分当前合并</a-button>
<a-checkbox
:checked="(selectedElement as any).printRepeated === true"
@update:checked="updateField('printRepeated', !!$event)"
>
多页时每页重复显示该表
</a-checkbox>
<div class="free-table-merge-tip">不勾选时仅在首页显示一次(与下方明细分页无关)。</div>
</a-space>
</details>
<details class="section-card" open>

View File

@@ -18,15 +18,22 @@ function roundToGrid(value: number, gridSize: number) {
return Math.round(value / gridSize) * gridSize;
}
export type PageMarginsMm = { top: number; right: number; bottom: number; left: number };
export function calcDragRect(
startRect: Rect,
pageSize: { width: number; height: number },
deltaX: number,
deltaY: number,
gridSize: number,
margins?: PageMarginsMm,
) {
const x = clamp(roundToGrid(startRect.x + deltaX, gridSize), 0, pageSize.width - startRect.w);
const y = clamp(roundToGrid(startRect.y + deltaY, gridSize), 0, pageSize.height - startRect.h);
const ml = margins?.left ?? 0;
const mr = margins?.right ?? 0;
const mt = margins?.top ?? 0;
const mb = margins?.bottom ?? 0;
const x = clamp(roundToGrid(startRect.x + deltaX, gridSize), ml, pageSize.width - mr - startRect.w);
const y = clamp(roundToGrid(startRect.y + deltaY, gridSize), mt, pageSize.height - mb - startRect.h);
return { ...startRect, x, y };
}
@@ -37,22 +44,35 @@ export function calcResizeRect(
deltaX: number,
deltaY: number,
gridSize: number,
margins?: PageMarginsMm,
) {
const ml = margins?.left ?? 0;
const mr = margins?.right ?? 0;
const mt = margins?.top ?? 0;
const mb = margins?.bottom ?? 0;
const next = { ...startRect };
if (direction.includes('e')) {
next.w = clamp(roundToGrid(startRect.w + deltaX, gridSize), MIN_SIZE, pageSize.width - startRect.x);
next.w = clamp(roundToGrid(startRect.w + deltaX, gridSize), MIN_SIZE, pageSize.width - mr - startRect.x);
}
if (direction.includes('s')) {
next.h = clamp(roundToGrid(startRect.h + deltaY, gridSize), MIN_SIZE, pageSize.height - startRect.y);
next.h = clamp(roundToGrid(startRect.h + deltaY, gridSize), MIN_SIZE, pageSize.height - mb - startRect.y);
}
if (direction.includes('w')) {
const newX = clamp(roundToGrid(startRect.x + deltaX, gridSize), 0, startRect.x + startRect.w - MIN_SIZE);
next.w = clamp(roundToGrid(startRect.w + (startRect.x - newX), gridSize), MIN_SIZE, pageSize.width - newX);
const newX = clamp(
roundToGrid(startRect.x + deltaX, gridSize),
ml,
startRect.x + startRect.w - MIN_SIZE,
);
next.w = clamp(roundToGrid(startRect.w + (startRect.x - newX), gridSize), MIN_SIZE, pageSize.width - mr - newX);
next.x = newX;
}
if (direction.includes('n')) {
const newY = clamp(roundToGrid(startRect.y + deltaY, gridSize), 0, startRect.y + startRect.h - MIN_SIZE);
next.h = clamp(roundToGrid(startRect.h + (startRect.y - newY), gridSize), MIN_SIZE, pageSize.height - newY);
const newY = clamp(
roundToGrid(startRect.y + deltaY, gridSize),
mt,
startRect.y + startRect.h - MIN_SIZE,
);
next.h = clamp(roundToGrid(startRect.h + (startRect.y - newY), gridSize), MIN_SIZE, pageSize.height - mb - newY);
next.y = newY;
}
return next;

View File

@@ -47,6 +47,7 @@ export function mapElementToTemplateStyle(element: NativeElement) {
}
: element.type === 'freeTable'
? {
printRepeated: (element as any).printRepeated === true,
rowCount: Number((element as any).rowCount || 1),
colCount: Number((element as any).colCount || 1),
borderColor: (element as any).borderColor || '#d9d9d9',

View File

@@ -412,33 +412,79 @@ export async function renderNativePrintHtml(schema: NativeTemplateSchema, data:
.filter(Boolean)
.join(';');
const style = (topMm: number) => [`left:${renderX}mm`, `top:${topMm}mm`, styleParts].join(';');
// 明细表 / 固定行表自行按 chunk 纵向堆叠,禁止套入「每文档页重复」循环,否则整表会重复 pageCount 次导致表头/合计错乱
if (item.type === 'table' || item.type === 'detailTable') {
const tableMode = String((item as any).tableHeightMode || 'autoPage');
if (tableMode === 'fixedRows') {
const pageTables = await renderFixedRowsTablePages(item, data);
return pageTables
.map((tableHtml, pageIndex) => {
const pageTop = resolveDetailTableChunkTopMm(
schema,
pageIndex,
renderY,
Number((item as any).h || 1),
repeatHeaderByPage,
headerBandHeight,
pageCount,
);
return `<div class="qhmes-native-table-chunk" style="${style(pageTop)};overflow:visible;height:auto;">${tableHtml}</div>`;
})
.join('');
}
const columns = normalizeTableWidths(item as NativeTableElement);
const autoChunks = computeAutoPageRowChunks(item as NativeTableElement, schema, data, {
repeatHeaderBandMm: headerBandHeight,
repeatHeaderEnabled: repeatHeaderByPage,
});
const fullRows = resolveTableRows(item as NativeTableElement, data);
const footerMode = String((item as any).footerTotalMode || 'overall');
const pageTablesHtml = await Promise.all(
autoChunks.map(async (chunkRows, chunkIdx) => {
const isLast = chunkIdx === autoChunks.length - 1;
const showFooter = footerMode === 'page' ? true : isLast;
const footerRows = footerMode === 'page' ? chunkRows : fullRows;
return renderTablePage(item, columns as any[], chunkRows, showFooter, footerRows);
}),
);
return pageTablesHtml
.map(
(tableHtml, chunkIdx) =>
`<div class="qhmes-native-table-chunk" style="${style(
resolveDetailTableChunkTopMm(
schema,
chunkIdx,
renderY,
Number((item as any).h || 1),
repeatHeaderByPage,
headerBandHeight,
pageCount,
),
)};overflow:visible;height:auto;">${tableHtml}</div>`,
)
.join('');
}
// 自由表格:是否每页重复仅由元素 printRepeated 控制,与报表头/页眉区「每页重复」无关
if (item.type === 'freeTable') {
const repeatFree = (item as NativeFreeTableElement).printRepeated === true && pageCount > 1;
const ftPages = repeatFree ? Array.from({ length: pageCount }, (_v, i) => i + 1) : [1];
const ftParts = await Promise.all(
ftPages.map(async (pageNo) => {
const top = renderY + (repeatFree ? (pageNo - 1) * schema.page.height : 0);
const ftHtml = await renderFreeTable(item as NativeFreeTableElement, data);
return `<div style="${style(top)};overflow:visible;">${ftHtml}</div>`;
}),
);
return ftParts.join('');
}
const shouldRepeat = (repeatReportHeader || repeatHeaderElement) && pageCount > 1;
const pages = shouldRepeat ? Array.from({ length: pageCount }, (_v, i) => i + 1) : [1];
const htmlByPage = await Promise.all(
pages.map(async (pageNo) => {
const top = renderY + (shouldRepeat ? (pageNo - 1) * schema.page.height : 0);
if (item.type === 'table' || item.type === 'detailTable') {
const tableMode = String((item as any).tableHeightMode || 'autoPage');
if (tableMode === 'fixedRows') {
const pageTables = await renderFixedRowsTablePages(item, data);
if (shouldRepeat) {
const firstPageTable = pageTables[0] || '';
return `<div style="${style(top)};overflow:visible;height:auto;">${firstPageTable}</div>`;
}
return pageTables
.map((tableHtml, pageIndex) => {
const pageTop = renderY + pageIndex * schema.page.height;
return `<div style="${style(pageTop)};overflow:visible;height:auto;">${tableHtml}</div>`;
})
.join('');
}
return `<div style="${style(top)};overflow:visible;height:auto;">${await renderTable(item, data)}</div>`;
}
if (item.type === 'freeTable') {
// 自由表格:避免 overflow:hidden 裁掉底边/竖线边框;由行高+box-sizing 控制占位
const ftHtml = await renderFreeTable(item as NativeFreeTableElement, data);
return `<div style="${style(top)};overflow:visible;">${ftHtml}</div>`;
}
if (item.type === 'qrcode') {
const value = resolveBoundValue(item, data) ?? (item as any).value;
try {
@@ -469,6 +515,13 @@ export async function renderNativePrintHtml(schema: NativeTemplateSchema, data:
.map((_v, index) => `<div style="position:absolute;left:0;top:${(index + 1) * schema.page.height}mm;width:${schema.page.width}mm;height:0;page-break-before:always;"></div>`)
.join('')
: '';
const screenPageMarks =
pageCount > 1
? Array.from({ length: pageCount - 1 }, (_v, k) => {
const t = (k + 1) * schema.page.height;
return `<div class="qhmes-native-screen-page-sep" aria-hidden="true" style="position:absolute;left:0;width:100%;top:${t}mm;pointer-events:none;z-index:10000;"></div>`;
}).join('')
: '';
const pageMargin = resolvePageMarginCss(schema.page.margin);
return `<!doctype html>
<html>
@@ -481,12 +534,60 @@ export async function renderNativePrintHtml(schema: NativeTemplateSchema, data:
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
@media screen {
html { background: #525659; }
body {
margin: 0;
padding: 28px 20px 48px;
background: #525659;
min-height: 100vh;
box-sizing: border-box;
}
.qhmes-native-print-root {
margin: 0 auto;
position: relative;
box-shadow:
0 1px 2px rgba(0, 0, 0, 0.14),
0 4px 12px rgba(0, 0, 0, 0.1),
0 12px 28px rgba(0, 0, 0, 0.08);
background: #fff;
border-radius: 1px;
}
/* 分页缝隙:仿多页纸张之间的装订区 */
.qhmes-native-screen-page-sep {
height: 14px;
margin-top: -7px;
margin-left: -10px;
width: calc(100% + 20px);
background: #525659;
border: none;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06);
}
.qhmes-native-table-chunk table {
border-collapse: collapse;
}
.qhmes-native-table-chunk thead {
display: table-header-group;
}
.qhmes-native-table-chunk tbody tr {
break-inside: avoid;
}
}
@media print {
html, body {
background: transparent !important;
padding: 0 !important;
}
.qhmes-native-print-root { box-shadow: none !important; }
.qhmes-native-screen-page-sep { display: none !important; }
}
</style>
</head>
<body>
<div style="position:relative;width:${schema.page.width}mm;min-height:${totalHeight}mm;height:auto;overflow:visible;box-sizing:border-box;">
<div class="qhmes-native-print-root" style="--qhmes-page-h:${schema.page.height}mm;position:relative;width:${schema.page.width}mm;min-height:${totalHeight}mm;height:auto;overflow:visible;box-sizing:border-box;">
${content}
${pageBreakGuides}
${screenPageMarks}
</div>
</body>
</html>`;
@@ -503,15 +604,240 @@ function resolvePageMarginCss(margin?: [number, number, number, number]) {
return `${top}mm ${right}mm ${bottom}mm ${left}mm`;
}
/** 页面上边距mm与 @page margin、版心高度计算一致续页顶排内容需从纸面顶向下偏移该值 */
function resolvePageMarginTopMm(schema: NativeTemplateSchema): number {
const m = schema.page?.margin;
if (!Array.isArray(m) || m.length < 4) {
return 0;
}
return Math.max(0, Number(m[0] || 0));
}
/** 可打印内容区高度(纸张高度减去上、下边距,与 @page margin 一致) */
export function resolvePageInnerHeightMm(schema: NativeTemplateSchema): number {
const ph = Math.max(1, Number(schema.page?.height || 297));
const m = schema.page?.margin;
if (!Array.isArray(m) || m.length < 4) {
return ph;
}
const top = Math.max(0, Number(m[0] || 0));
const bottom = Math.max(0, Number(m[2] || 0));
return Math.max(10, ph - top - bottom);
}
/**
* 是否存在「每页重复」的自由表格会占用明细续页顶部的纵向空间。
* 仅判断「整表在明细设计矩形下方」(free.y >= detail底边) 为 false 即视为约束续页(含:在明细上方、与明细纵向重叠、与明细同顶并排等)。
* 原先用 free底 <= detail顶 会漏掉「并排但自由表更矮/更高」等仍会与顶排明细叠在一起的情况。
*/
function repeatingFreeTableConstrainsDetailContinuation(schema: NativeTemplateSchema, detailEl: { y?: number; h?: number }): boolean {
const dy = Number(detailEl.y || 0);
const dBottom = dy + Math.max(0.01, Number(detailEl.h || 1));
return schema.elements.some((el: any) => {
if (el?.type !== 'freeTable' || el?.visible === false || el.printRepeated !== true) return false;
const fy = Number(el.y || 0);
// 自由表整表在明细设计区域之下,续页明细可顶到版心而不与该重复块相撞
if (fy >= dBottom - 0.02) return false;
return true;
});
}
/**
* 明细/表格分页块顶边mm相对整份 HTML 根节点):首块沿用设计器 y。
* 续页:若上方无重复自由表格,顶到版心(或仅报表头带下)以免留白;若有重复自由表格,沿用设计顶距以免与自由表格重叠。
*/
function resolveDetailTableChunkTopMm(
schema: NativeTemplateSchema,
chunkIdx: number,
designTopMm: number,
detailHeightMm: number,
repeatHeaderByPage: boolean,
headerBandHeightMm: number,
pageCount: number,
): number {
const pageH = Math.max(1, Number(schema.page?.height || 297));
const pageBase = chunkIdx * pageH;
if (chunkIdx === 0) {
return pageBase + Math.max(0, designTopMm);
}
if (pageCount > 1 && repeatingFreeTableConstrainsDetailContinuation(schema, { y: designTopMm, h: detailHeightMm })) {
return pageBase + Math.max(0, designTopMm);
}
const marginTop = resolvePageMarginTopMm(schema);
const continuation =
marginTop +
(repeatHeaderByPage && headerBandHeightMm > 0 ? Math.max(0, headerBandHeightMm) : 0);
return pageBase + continuation;
}
const MM_TO_CSS_PX = 96 / 25.4;
const CSS_PX_PER_MM = MM_TO_CSS_PX;
/**
* 估算表格数据行在纸面上的高度mm。换行/二维码等会使实际行高远大于设计 rowHeight
* 若仍按固定 rowHeight 分页会导致整表落在同一 chunk、页数为 1 且预览溢出灰底。
*/
function estimateTableBodyRowHeightMm(element: NativeTableElement, row: Record<string, any>, columns: any[]): number {
const baseRow = Math.max(0.01, Number(element.rowHeight || 8));
let maxH = baseRow;
const padHmm = 4; // 与单元格 padding:2mm 上下大致相当
for (const column of columns) {
const fieldKey = column.bindField || column.field;
const cellValue = getValueByPath(row || {}, fieldKey);
const contentType = String((column as any).contentType || 'text');
const colWMm = Math.max(1, Number(column.width || 30));
const nowrap = (column as any).autoWrap === false;
const bodyBaseSize = (column as any)?.useCustomFontSize
? Number((column as any)?.fontSize || 12)
: Number(element.bodyFontSize || 12);
const displayValue = isNumericColumn(column) ? formatNumericValue(cellValue, column) : String(cellValue ?? '');
const fontSize = resolvePrintAutoFontSize(column as any, displayValue, colWMm, element.rowHeight || 8, bodyBaseSize);
if (contentType === 'qrcode' || contentType === 'barcode') {
const fillCell = column?.fillCell !== false;
const scale = Math.max(10, Math.min(100, Number(column?.contentScale || 100)));
const sideMm = fillCell ? colWMm * 0.92 : colWMm * (scale / 100) * 0.92;
maxH = Math.max(maxH, sideMm + padHmm);
continue;
}
if (contentType === 'image') {
const fillCell = column?.fillCell !== false;
const scale = Math.max(10, Math.min(100, Number(column?.contentScale || 100)));
const hMm = fillCell ? colWMm * 0.62 : colWMm * (scale / 100) * 0.62;
maxH = Math.max(maxH, hMm + padHmm);
continue;
}
if (nowrap) {
maxH = Math.max(maxH, baseRow);
continue;
}
const text = displayValue;
if (!text.length) {
maxH = Math.max(maxH, baseRow);
continue;
}
const colWpx = colWMm * CSS_PX_PER_MM;
const charWpx = Math.max(1, fontSize * 0.62);
const innerWpx = Math.max(8, colWpx - 2 * CSS_PX_PER_MM * 2);
const charsPerLine = Math.max(4, Math.floor(innerWpx / charWpx));
const lines = Math.max(1, Math.ceil(text.length / charsPerLine));
const lineHeightPx = fontSize * 1.3;
const textHmm = (lines * lineHeightPx) / CSS_PX_PER_MM;
maxH = Math.max(maxH, textHmm + padHmm, baseRow);
}
return Math.max(baseRow, maxH);
}
function sumRowHeightsMm(heights: number[], from: number, count: number): number {
let s = 0;
const end = Math.min(from + count, heights.length);
for (let j = from; j < end; j += 1) {
s += heights[j];
}
return s;
}
/**
* autoPage 明细表:按版心高度、表头顶、行高与页脚占位拆分数据行(与浏览器打印分页接近)
*/
export function computeAutoPageRowChunks(
element: NativeTableElement,
schema: NativeTemplateSchema,
data: Record<string, any>,
opts: { repeatHeaderBandMm: number; repeatHeaderEnabled: boolean },
): Record<string, any>[][] {
const rows = resolveTableRows(element, data);
if (!rows.length) {
return [[]];
}
const columns = normalizeTableWidths(element);
const rowHeights = rows.map((r) => estimateTableBodyRowHeightMm(element, r, columns as any[]));
const innerH = resolvePageInnerHeightMm(schema);
const y0 = Math.max(0, Number(element.y || 0));
const rowH = Math.max(0.01, Number(element.rowHeight || 8));
const headerH = element.showHeader ? Math.max(0, Number(element.headerHeight || 10)) : 0;
const footerMode = String((element as any).footerTotalMode || 'overall');
const footerOff = (element as any).footerShowTotal === false;
const needFooterEveryPage = !footerOff && footerMode === 'page';
const needFooterLastOnly = !footerOff && footerMode === 'overall';
const footerMm = Math.max(rowH, 6);
const band =
opts.repeatHeaderEnabled && opts.repeatHeaderBandMm > 0 ? Math.max(0, opts.repeatHeaderBandMm) : 0;
const repeatFreeConstrains = repeatingFreeTableConstrainsDetailContinuation(schema, element);
const chunks: Record<string, any>[][] = [];
let i = 0;
let pageIdx = 0;
while (i < rows.length) {
const remaining = rows.length - i;
const avail =
pageIdx === 0
? innerH - y0 - headerH
: repeatFreeConstrains
? Math.max(rowH, innerH - y0 - headerH)
: Math.max(rowH, innerH - headerH - band);
const safeAvail = Math.max(rowH, avail);
let maxBodyMm = safeAvail;
if (needFooterEveryPage) {
maxBodyMm = Math.max(rowH, safeAvail - footerMm);
}
let take = 0;
let used = 0;
while (take < remaining && used + rowHeights[i + take] <= maxBodyMm + 0.02) {
used += rowHeights[i + take];
take += 1;
}
if (needFooterLastOnly && !needFooterEveryPage && remaining <= take) {
const bodyMm = sumRowHeightsMm(rowHeights, i, remaining);
if (bodyMm + footerMm <= safeAvail + 0.02) {
chunks.push(rows.slice(i, i + remaining));
break;
}
maxBodyMm = Math.max(rowH, safeAvail - footerMm);
take = 0;
used = 0;
while (take < remaining && used + rowHeights[i + take] <= maxBodyMm + 0.02) {
used += rowHeights[i + take];
take += 1;
}
}
if (take === 0) {
take = 1;
}
chunks.push(rows.slice(i, i + take));
i += take;
pageIdx += 1;
if (pageIdx > 5000) {
break;
}
}
return chunks.length ? chunks : [[]];
}
/** 与 renderNativePrintHtml 内部页数计算一致,供列表预览等比缩放使用 */
export function resolvePrintPageCount(schema: NativeTemplateSchema, data: Record<string, any>) {
const repeat = resolveRepeatHeaderConfig(schema);
const band = repeat.enabled ? resolveHeaderBandHeight(schema) : 0;
const tablePages = schema.elements
.filter((item) => item.type === 'table' || item.type === 'detailTable')
.map((item: any) => {
const rows = resolveTableRows(item as NativeTableElement, data);
const mode = String(item?.tableHeightMode || 'autoPage');
if (mode !== 'fixedRows') {
return 1;
const chunks = computeAutoPageRowChunks(item as NativeTableElement, schema, data, {
repeatHeaderBandMm: band,
repeatHeaderEnabled: repeat.enabled,
});
return Math.max(1, chunks.length);
}
const pageSize = Math.max(1, Number(item?.fixedRows || 5));
return Math.max(1, Math.ceil(rows.length / pageSize));

View File

@@ -188,6 +188,8 @@ export interface NativeFreeTableInnerBorder {
export interface NativeFreeTableElement extends NativeElementBase {
type: 'freeTable';
/** 为 true 且文档多页时,打印/预览中每页重复渲染该自由表格;默认 false 仅首页显示一次 */
printRepeated?: boolean;
rowCount: number;
colCount: number;
/** 各列宽度mm长度与 colCount 一致;未设置时按元素 w 均分 */

View File

@@ -192,6 +192,7 @@ export function createElementByType(type: NativeElementType, zIndex: number, def
return {
...base,
type,
printRepeated: false,
w: 120,
h: 50,
rowCount: 3,

View File

@@ -0,0 +1,201 @@
/**
* PrintDot 本地桥接器Wails 客户端WebSocket 协议封装。
* 默认地址 ws://127.0.0.1:1122/ws ,与 PrintDot-Client 中 Bridge 默认端口一致。
*/
const LS_WS_URL = 'qhmes_print_dot_ws_url';
const LS_KEY = 'qhmes_print_dot_key';
export type PrintDotPrinter = { name: string; isDefault?: boolean };
export function getPrintDotBridgeConfig() {
return {
wsUrl: localStorage.getItem(LS_WS_URL) || 'ws://127.0.0.1:1122/ws',
key: localStorage.getItem(LS_KEY) || '',
};
}
export function setPrintDotBridgeConfig(wsUrl: string, key: string) {
localStorage.setItem(LS_WS_URL, (wsUrl || '').trim());
localStorage.setItem(LS_KEY, key ?? '');
}
function buildWsUrl(wsUrl: string, key: string) {
const base = (wsUrl || '').trim();
const k = (key || '').trim();
if (!k) {
return base;
}
const sep = base.includes('?') ? '&' : '?';
return `${base}${sep}key=${encodeURIComponent(k)}`;
}
/** 连接后服务端会先推送 printer_list本方法读取该列表后关闭连接 */
export function fetchPrintDotPrinters(timeoutMs = 8000): Promise<PrintDotPrinter[]> {
const { wsUrl, key } = getPrintDotBridgeConfig();
return new Promise((resolve, reject) => {
let done = false;
const ws = new WebSocket(buildWsUrl(wsUrl, key));
const timer = window.setTimeout(() => {
if (done) {
return;
}
done = true;
try {
ws.close();
} catch {
// ignore
}
reject(new Error('连接 PrintDot 超时,请确认客户端已启动且 WebSocket 地址正确'));
}, timeoutMs);
const finish = (fn: () => void) => {
if (done) {
return;
}
done = true;
window.clearTimeout(timer);
try {
ws.close();
} catch {
// ignore
}
fn();
};
ws.onmessage = (ev) => {
try {
const data = JSON.parse(ev.data as string);
if (data?.type === 'printer_list' && Array.isArray(data.data)) {
const list = data.data
.map((p: any) => ({
name: String(p?.name || '').trim(),
isDefault: p?.isDefault === true,
}))
.filter((p: PrintDotPrinter) => !!p.name);
finish(() => resolve(list));
}
} catch {
// ignore
}
};
ws.onerror = () => {
finish(() => reject(new Error('无法连接 PrintDot WebSocket请检查地址与客户端是否运行')));
};
});
}
/** 将 PrintDot 返回的英文错误转为带处理步骤的提示(便于运维排查) */
function enhancePrintDotErrorMessage(raw: string): string {
const m = String(raw || '').trim();
if (/SumatraPDF\.exe not found/i.test(m) || /SUMATRAPDF_PATH/i.test(m)) {
return `${m}。本地处理PrintDot 依赖 SumatraPDF 静默打印 PDF。请安装 Sumatra PDF 后任选其一:将 SumatraPDF.exe 放在 PrintDot 客户端 exe 同目录;或将 Sumatra 安装目录加入系统 PATH或设置用户/系统环境变量 SUMATRAPDF_PATH 指向 SumatraPDF.exe 的完整路径,然后重启 PrintDot。`;
}
return m;
}
/**
* 发送 PDF 打印任务content 为 Base64 PDF可为纯 Base64 或 data:application/pdf;base64, 前缀)
* 连接建立后服务端可能先推送 printer_list需忽略直至收到 status。
*/
export function printDotSendPdf(params: {
printer: string;
pdfBase64: string;
jobName?: string;
copies?: number;
timeoutMs?: number;
}): Promise<{ ok: boolean; message: string }> {
const { wsUrl, key } = getPrintDotBridgeConfig();
const timeout = params.timeoutMs ?? 180000;
return new Promise((resolve, reject) => {
let settled = false;
const ws = new WebSocket(buildWsUrl(wsUrl, key));
const timer = window.setTimeout(() => {
if (settled) {
return;
}
settled = true;
try {
ws.close();
} catch {
// ignore
}
reject(new Error('PrintDot 打印等待结果超时'));
}, timeout);
const finishOk = (ok: boolean, message: string) => {
if (settled) {
return;
}
settled = true;
window.clearTimeout(timer);
try {
ws.close();
} catch {
// ignore
}
resolve({ ok, message });
};
ws.onopen = () => {
let content = String(params.pdfBase64 || '').trim();
if (content.startsWith('data:')) {
const idx = content.indexOf(',');
if (idx !== -1) {
content = content.slice(idx + 1);
}
}
const payload: Record<string, unknown> = {
printer: String(params.printer || '').trim(),
content,
job: {
name: String(params.jobName || 'QH-MES').trim() || 'QH-MES',
copies: Math.max(1, Number(params.copies) || 1),
},
};
ws.send(JSON.stringify(payload));
};
ws.onmessage = (ev) => {
try {
const data = JSON.parse(ev.data as string);
if (data?.type === 'printer_list') {
return;
}
if (data?.status) {
const ok = data.status === 'success';
const rawMsg = String(data.message || '');
finishOk(ok, ok ? rawMsg : enhancePrintDotErrorMessage(rawMsg));
}
} catch {
finishOk(false, 'PrintDot 返回无法解析');
}
};
ws.onerror = () => {
if (settled) {
return;
}
settled = true;
window.clearTimeout(timer);
try {
ws.close();
} catch {
// ignore
}
reject(new Error('PrintDot WebSocket 错误'));
};
});
}
/** 将列表页的「系统默认」或空选择解析为桥接器返回的默认打印机名称 */
export function resolvePrintDotPrinterName(selectedValue: string, printers: PrintDotPrinter[]): string {
const s = String(selectedValue || '').trim();
if (s && s !== '__system_default__') {
return s;
}
const def = printers.find((p) => p.isDefault);
return def?.name || printers[0]?.name || '';
}

View File

@@ -0,0 +1,212 @@
/** 将可打印 HTML 片段(非完整文档)转为 PDF Base64供 PrintDot / 后端队列等使用 */
function mmToPx(mm: number) {
return (mm * 96) / 25.4;
}
function pxToMm(px: number) {
return (px * 25.4) / 96;
}
/**
* 原生模板大量 position:absolute子块可能低于父级 scrollHeight
* 用后代包围盒与 scroll 尺寸取大,避免栅格化高度不足、末页空白。
*/
function measureCaptureExtentPx(root: HTMLElement): { sw: number; sh: number } {
const base = root.getBoundingClientRect();
let maxR = 0;
let maxB = 0;
const visit = (el: Element) => {
const r = (el as HTMLElement).getBoundingClientRect();
maxR = Math.max(maxR, r.right - base.left);
maxB = Math.max(maxB, r.bottom - base.top);
Array.from(el.children).forEach(visit);
};
visit(root);
const sw = Math.max(1, Math.ceil(maxR), root.scrollWidth, root.clientWidth);
const sh = Math.max(1, Math.ceil(maxB), root.scrollHeight, root.clientHeight);
return { sw, sh };
}
function arrayBufferToBase64(buffer: ArrayBuffer): string {
const bytes = new Uint8Array(buffer);
const chunkSize = 0x8000;
let binary = '';
for (let i = 0; i < bytes.length; i += chunkSize) {
const chunk = bytes.subarray(i, i + chunkSize);
binary += String.fromCharCode(...chunk);
}
return btoa(binary);
}
/** 从 renderNativePrintHtml 等生成的完整 HTML 文档中取出 body 内层,便于套入与 hiprint 相同的 PDF 容器逻辑 */
export function extractBodyInnerHtmlFromFullDocument(fullHtml: string): string {
try {
const doc = new DOMParser().parseFromString(fullHtml, 'text/html');
const inner = doc.body?.innerHTML?.trim();
return inner || fullHtml;
} catch {
return fullHtml;
}
}
export type BuildPdfFromHtmlOptions = {
/**
* true默认widthMm/heightMm 为单张纸宽高,按高度纵向切片为多页 PDF对齐浏览器打印分页。
* false整张版面压成一页 PDF长图一页一般仅特殊场景使用
*/
paginate?: boolean;
};
/**
* @param html 内部 HTML 片段(无外层 html/body
* @param widthMm 纸张宽度 mm分页模式下每页同宽
* @param heightMm 纸张高度 mm分页模式下为「每一页」高度非分页时为版心高度下限
*/
export async function buildPdfBase64FromHtmlFragment(
html: string,
widthMm: number,
heightMm: number,
options: BuildPdfFromHtmlOptions = {},
): Promise<string> {
const paginate = options.paginate !== false;
const [{ jsPDF }, html2canvasModule] = await Promise.all([import('jspdf'), import('html2canvas')]);
const html2canvas = html2canvasModule.default;
const container = document.createElement('div');
container.style.position = 'fixed';
container.style.left = '-20000px';
container.style.top = '0';
container.style.width = 'auto';
container.style.maxWidth = 'none';
container.style.overflow = 'visible';
container.style.background = '#fff';
container.style.zIndex = '-1';
container.innerHTML = `<div class="lodop-print-root" style="width:${widthMm}mm;min-width:${widthMm}mm;margin:0;padding:0;background:#fff;overflow:visible;box-sizing:border-box;">${html}</div>`;
document.body.appendChild(container);
try {
const target = (container.querySelector('.lodop-print-root') || container) as HTMLElement;
await new Promise((resolve) => setTimeout(resolve, 400));
const measure = () => {
const sw = Math.max(target.scrollWidth, target.clientWidth, 1);
const sh = Math.max(target.scrollHeight, target.clientHeight, 1);
return { sw, sh };
};
let { sw, sh } = measure();
const minWpx = mmToPx(Math.max(1, widthMm));
if (sw < minWpx) {
target.style.width = `${minWpx}px`;
}
if (sw > minWpx) {
target.style.width = `${sw}px`;
}
({ sw, sh } = measure());
let ext = measureCaptureExtentPx(target);
sw = Math.max(sw, ext.sw);
sh = Math.max(sh, ext.sh);
target.style.minHeight = `${sh}px`;
target.style.width = `${Math.max(minWpx, sw)}px`;
({ sw, sh } = measure());
ext = measureCaptureExtentPx(target);
sw = Math.max(sw, ext.sw);
sh = Math.max(sh, ext.sh);
const MAX_EDGE = 12000;
let scale = 2;
while (Math.max(sw, sh) * scale > MAX_EDGE && scale > 0.75) {
scale -= 0.25;
}
const canvas = await html2canvas(target, {
backgroundColor: '#ffffff',
scale,
useCORS: true,
allowTaint: true,
logging: false,
width: sw,
height: sh,
windowWidth: sw,
windowHeight: sh,
onclone: (_clonedDoc, clonedEl) => {
const root = (clonedEl.querySelector?.('.lodop-print-root') as HTMLElement) || (clonedEl as HTMLElement);
if (!root) {
return;
}
root.style.minHeight = `${sh}px`;
root.style.width = `${sw}px`;
root.style.overflow = 'visible';
const relaxOverflow = (el: HTMLElement) => {
const st = el.style;
if (st.overflow === 'hidden' || st.overflowX === 'hidden' || st.overflowY === 'hidden') {
st.setProperty('overflow', 'visible', 'important');
}
Array.from(el.children).forEach((c) => relaxOverflow(c as HTMLElement));
};
relaxOverflow(root);
},
});
const cw = Math.max(1, canvas.width);
const ch = Math.max(1, canvas.height);
const pad = 1;
if (paginate) {
const sheetW = Math.max(widthMm, pxToMm(sw) + pad);
const sheetH = Math.max(1, heightMm);
const sliceH = Math.max(1, Math.round(mmToPx(sheetH) * scale));
const pdf = new jsPDF({ unit: 'mm', format: [sheetW, sheetH] });
let y = 0;
let first = true;
/** 余量不足一页高的 2% 时视为测量噪声,避免多出一页空白 */
const tailNoisePx = Math.max(8, Math.floor(sliceH * 0.02));
while (y < ch) {
const remain = ch - y;
if (!first && remain <= tailNoisePx) {
break;
}
const hPx = Math.min(sliceH, remain);
if (hPx < 1) {
break;
}
const pageCanvas = document.createElement('canvas');
pageCanvas.width = cw;
pageCanvas.height = hPx;
const ctx = pageCanvas.getContext('2d');
if (ctx) {
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, cw, hPx);
ctx.drawImage(canvas, 0, y, cw, hPx, 0, 0, cw, hPx);
}
const imgData = pageCanvas.toDataURL('image/jpeg', 0.92);
const imgHmm = sheetH * (hPx / sliceH);
if (!first) {
pdf.addPage();
}
first = false;
pdf.addImage(imgData, 'JPEG', 0, 0, sheetW, imgHmm);
y += sliceH;
}
return arrayBufferToBase64(pdf.output('arraybuffer'));
}
// 单页长图模式paginate: false
const contentWidthMm = pxToMm(sw);
const contentHeightMm = pxToMm(sh);
const minW = Math.max(widthMm, contentWidthMm) + pad;
const minH = Math.max(heightMm, contentHeightMm) + pad;
const canvasRatio = cw / ch;
let pdfH = Math.max(minH, minW / canvasRatio);
let pdfW = pdfH * canvasRatio;
if (pdfW < minW) {
pdfW = minW;
pdfH = pdfW / canvasRatio;
}
const pdf = new jsPDF({ unit: 'mm', format: [pdfW, pdfH] });
const imgData = canvas.toDataURL('image/jpeg', 0.92);
pdf.addImage(imgData, 'JPEG', 0, 0, pdfW, pdfH);
return arrayBufferToBase64(pdf.output('arraybuffer'));
} finally {
container.remove();
}
}

View File

@@ -0,0 +1,38 @@
import type { NativeTemplateSchema } from '../native/core/types';
import { renderNativePrintHtml } from '../native/core/printRenderer';
import { buildPdfBase64FromHtmlFragment, extractBodyInnerHtmlFromFullDocument } from './printHtmlToPdfBase64';
import { fetchPrintDotPrinters, printDotSendPdf, resolvePrintDotPrinterName } from './printDotBridge';
const PRINTER_STORAGE_KEY = 'print_template_selected_printer';
/**
* 原生模板:渲染 HTML → 转 PDF → 经 PrintDot 本地桥接器送打印机
*/
export async function printNativeSchemaViaPrintDot(params: {
schema: NativeTemplateSchema;
data: Record<string, any>;
jobName?: string;
/** 与模板列表一致:可为具体打印机名或 __system_default__缺省读 localStorage */
printerSelection?: string;
}): Promise<void> {
const fullHtml = await renderNativePrintHtml(params.schema, params.data);
const inner = extractBodyInnerHtmlFromFullDocument(fullHtml);
const pdfBase64 = await buildPdfBase64FromHtmlFragment(inner, params.schema.page.width, params.schema.page.height, {
paginate: true,
});
const printers = await fetchPrintDotPrinters();
const fromStore = params.printerSelection ?? localStorage.getItem(PRINTER_STORAGE_KEY) ?? '__system_default__';
const resolved = resolvePrintDotPrinterName(fromStore, printers);
if (!resolved) {
throw new Error('未解析到可用打印机:请在模板列表选择打印机,或启动 PrintDot 后刷新打印机列表');
}
const result = await printDotSendPdf({
printer: resolved,
pdfBase64,
jobName: params.jobName,
timeoutMs: 180000,
});
if (!result.ok) {
throw new Error(result.message || 'PrintDot 打印失败');
}
}