新增PrintDot桥接功能,支持本地打印机连接和配置,优化打印模板设计,允许多页表格重复显示,改进打印预览和设计器界面,确保用户体验流畅。
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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 均分 */
|
||||
|
||||
@@ -192,6 +192,7 @@ export function createElementByType(type: NativeElementType, zIndex: number, def
|
||||
return {
|
||||
...base,
|
||||
type,
|
||||
printRepeated: false,
|
||||
w: 120,
|
||||
h: 50,
|
||||
rowCount: 3,
|
||||
|
||||
201
jeecgboot-vue3/src/views/print/template/utils/printDotBridge.ts
Normal file
201
jeecgboot-vue3/src/views/print/template/utils/printDotBridge.ts
Normal 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 || '';
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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 打印失败');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user