新增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

@@ -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') {