优化打印模板界面,更新打印机选择逻辑,支持PrintDot桥接打印,改进快速打印功能,增强用户体验。新增图标显示于报表节和组件框按钮,调整布局以提升可用性。

This commit is contained in:
geht
2026-04-17 20:30:07 +08:00
parent 93b2208675
commit ffb6f2bd0a
2 changed files with 171 additions and 349 deletions

View File

@@ -10,7 +10,7 @@
allow-clear
show-search
option-filter-prop="label"
placeholder="选择打印机(本地/网络)"
:placeholder="printerSelectPlaceholder"
/>
<a-input
v-model:value="manualPrinterName"
@@ -58,50 +58,35 @@
</BasicTable>
<PrintTemplateModal @register="registerModal" @success="handleSuccess" />
<NativeTemplateListPreviewModal v-model:open="nativeListPreviewOpen" :template-id="nativeListPreviewTemplateId" />
<a-modal v-model:open="quickPrintVisible" title="快速打印" width="820px" :confirm-loading="quickPrintLoading" @ok="handleQuickPrint">
<a-modal
v-model:open="quickNativePrintOpen"
title="快速打印"
width="480px"
:footer="null"
destroy-on-close
>
<a-space direction="vertical" style="width: 100%" size="middle">
<a-radio-group v-model:value="quickPrintMode">
<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-alert
type="info"
show-icon
message="请选择原生模板"
description="将打开与列表「预览」相同的弹窗,在其中编辑模板 JSON、参数 JSON并使用「浏览器打印」或 PrintDot「桥接器打印」。"
/>
<div>
<div class="skill-field-label">模板编号</div>
<a-select
v-model:value="quickPrintForm.templateCode"
v-model:value="quickNativeTemplateCode"
:options="templateCodeOptions"
style="width: 280px"
style="width: 100%"
show-search
option-filter-prop="label"
placeholder="选择模板编号"
/>
<a-select
v-model:value="quickPrintForm.printerName"
:options="printerOptions"
style="width: 320px"
show-search
allow-clear
option-filter-prop="label"
placeholder="选择打印机(可为空使用系统默认)"
/>
</a-space>
<a-textarea
v-model:value="quickPrintForm.dataJson"
:rows="12"
placeholder='传入打印数据JSON例如{"docNo":"MO-001","mainTable":[{"materialCode":"M01","qty":10}]}'
/>
<a-divider plain orientation="left" style="margin: 4px 0">预览</a-divider>
<div style="font-size: 12px; color: rgba(0, 0, 0, 0.55); line-height: 1.6">
打开打印设计器并自动执行与工具栏预览相同的逻辑同一套 hiprint 预览样式注入与表格合并后处理确保与在设计器内预览一致
</div>
<a-button type="button" :loading="quickPreviewLoading" @click.prevent.stop="handleQuickPrintDesignerPreview">
预览与设计器一致
</a-button>
<a-space style="width: 100%; justify-content: flex-end">
<a-button @click="quickNativePrintOpen = false">取消</a-button>
<a-button type="primary" :loading="quickNativePrintLoading" @click="confirmQuickOpenNativePreview">打开预览</a-button>
</a-space>
</a-space>
</a-modal>
@@ -189,7 +174,6 @@
deleteOne,
batchDelete,
queryPrinters,
directPrint,
directPrintPdf,
queryByCode,
queryById,
@@ -198,20 +182,12 @@
import { resolveProviders } from './hiprint/qhmesProvider';
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 { buildPdfBase64FromHtmlFragment } from './utils/printHtmlToPdfBase64';
import {
fetchPrintDotPrinters,
getPrintDotBridgeConfig,
printDotSendPdf,
resolvePrintDotPrinterName,
setPrintDotBridgeConfig,
} from './utils/printDotBridge';
import { normalizeImportedNativeSchema } from './native/core/nativeSchemaNormalize';
import { renderNativePrintHtml } from './native/core/printRenderer';
import { PRINT_TEMPLATE_SELECTED_PRINTER_KEY } from './utils/printNativeViaPrintDot';
defineOptions({ name: 'PrintTemplateList' });
@@ -226,18 +202,10 @@
const selectedPrinterName = ref<string | undefined>();
const printerOptions = ref<Array<{ label: string; value: string }>>([]);
const manualPrinterName = ref('');
const quickPrintVisible = ref(false);
const quickPrintLoading = ref(false);
const quickPreviewLoading = ref(false);
const quickNativePrintOpen = ref(false);
const quickNativeTemplateCode = ref<string>('');
const quickNativePrintLoading = ref(false);
const templateCodeOptions = ref<Array<{ label: string; value: string }>>([]);
const quickPrintForm = ref<{ templateCode: string; printerName?: string; dataJson: string }>({
templateCode: '',
printerName: '__system_default__',
dataJson: '',
});
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();
@@ -246,8 +214,16 @@
function persistPrintDotConfig() {
setPrintDotBridgeConfig(printDotWsUrl.value, printDotKey.value);
// 桥接开启时 WS/密钥变更后重新拉取桥接器打印机列表
if (printDotEnabled.value) {
void refreshPrinterOptions(false);
}
}
const printerSelectPlaceholder = computed(() =>
printDotEnabled.value ? '选择打印机PrintDot 桥接)' : '选择打印机(本地/网络)',
);
function onPrintDotEnabledChange() {
localStorage.setItem(LS_PRINT_DOT_ENABLED, printDotEnabled.value ? '1' : '0');
void refreshPrinterOptions(false);
@@ -338,6 +314,50 @@
const [registerTable, { reload }, { rowSelection, selectedRowKeys }] = tableContext;
async function refreshPrinterOptions(showMessage = true) {
const optionMap = new Map<string, { label: string; value: string }>();
// 保留「系统默认」:本地模式交给浏览器/队列;桥接模式由 resolvePrintDotPrinterName 映射为桥接器默认打印机
optionMap.set('__system_default__', { label: '系统默认打印机', value: '__system_default__' });
if (printDotEnabled.value) {
try {
const dotList = await fetchPrintDotPrinters();
dotList.forEach((p) => {
const name = String(p.name || '').trim();
if (!name) {
return;
}
const defMark = p.isDefault ? '(默认)' : '';
optionMap.set(name, { label: `${name}${defMark}`, value: name });
});
if (selectedPrinterName.value && !optionMap.has(selectedPrinterName.value)) {
optionMap.set(selectedPrinterName.value, {
label: `${selectedPrinterName.value}(手动)`,
value: selectedPrinterName.value,
});
}
printerOptions.value = Array.from(optionMap.values());
if (showMessage) {
if (dotList.length) {
createMessage.success(`已从 PrintDot 桥接识别 ${dotList.length} 台打印机`);
} else {
createMessage.warning('PrintDot 已连接但未返回打印机列表');
}
}
} catch (e: any) {
if (selectedPrinterName.value && !optionMap.has(selectedPrinterName.value)) {
optionMap.set(selectedPrinterName.value, {
label: `${selectedPrinterName.value}(手动)`,
value: selectedPrinterName.value,
});
}
printerOptions.value = Array.from(optionMap.values());
if (showMessage) {
createMessage.warning(`PrintDot${e?.message || '无法连接本地桥接器'}`);
}
}
return;
}
const payload = (await queryPrinters()) as Record<string, any>;
const names = [
...(Array.isArray(payload?.serverPrinters) ? payload.serverPrinters : []),
@@ -346,9 +366,6 @@
.map((item) => String(item || '').trim())
.filter(Boolean)
.filter((item, index, arr) => arr.indexOf(item) === index);
const optionMap = new Map<string, { label: string; value: string }>();
// 永远保留“系统默认打印机”兜底项,避免插件不可用时无法选择
optionMap.set('__system_default__', { label: '系统默认打印机', value: '__system_default__' });
names.forEach((item) => {
optionMap.set(item, { label: item, value: item });
});
@@ -359,31 +376,10 @@
});
}
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 if (!printDotEnabled.value) {
} else {
const reason = String(payload?.capability?.localReason || '').trim();
createMessage.warning(`服务端未返回可用打印机。${reason || '请在后端配置网络打印机后重试。'}`);
}
@@ -416,82 +412,50 @@
}
async function openQuickPrintModal() {
quickPrintForm.value.printerName = selectedPrinterName.value || '__system_default__';
quickPrintMode.value = 'templateStyle';
quickNativeTemplateCode.value = '';
if (!templateCodeOptions.value.length) {
await loadTemplateCodeOptions();
}
quickPrintVisible.value = true;
quickNativePrintOpen.value = true;
}
/**
* 跳转打印设计器并自动调用与设计器「预览」相同的 previewTemplate 逻辑(见 PrintDesigner runQuickPrintPreviewFromSessionStorage
*/
async function handleQuickPrintDesignerPreview() {
const templateCode = String(quickPrintForm.value.templateCode || '').trim();
/** 快速打印:仅支持原生模板,打开与列表相同的预览弹窗(浏览器打印 / PrintDot */
async function confirmQuickOpenNativePreview() {
const templateCode = String(quickNativeTemplateCode.value || '').trim();
if (!templateCode) {
createMessage.warning('请先选择模板编号');
return;
}
const dataText = String(quickPrintForm.value.dataJson || '').trim();
if (!dataText) {
createMessage.warning('请先输入打印数据 JSON');
return;
}
try {
JSON.parse(dataText);
} catch (error: any) {
createMessage.error(`打印数据JSON格式错误${error?.message || '未知错误'}`);
return;
}
quickPreviewLoading.value = true;
quickNativePrintLoading.value = true;
try {
const tpl = (await queryByCode(templateCode)) as Record<string, any>;
const id = String(tpl?.id ?? (tpl as any)?.result?.id ?? '').trim();
const row = (tpl?.result ?? tpl) as Record<string, any>;
const id = String(row?.id ?? tpl?.id ?? '').trim();
if (!id) {
createMessage.error(`未找到模板记录主键,无法打开设计器预览。返回字段:${Object.keys(tpl || {}).join(', ') || '空'}`);
createMessage.error('未找到模板记录,请确认编号是否正确');
return;
}
sessionStorage.setItem(
QUICK_PRINT_PREVIEW_STORAGE_KEY,
JSON.stringify({
dataJsonText: dataText,
}),
);
const previewQuery = {
id,
quickPrintPreview: '1',
_qpt: String(Date.now()),
} as Record<string, string>;
const navigateToDesignerPreview = async () => {
const isDuplicateNav = (err: unknown) => /redundant|duplicated|Avoided/i.test(String((err as any)?.message ?? err ?? ''));
const candidates = [
{ name: 'print-designer' as const, query: previewQuery },
{ path: '/print/designer', query: previewQuery },
];
let lastErr: unknown = null;
for (const loc of candidates) {
try {
await router.push(loc as any);
return;
} catch (e: any) {
lastErr = e;
if (isDuplicateNav(e)) {
await router.replace(loc as any);
return;
}
}
const templateJsonText = String(row?.templateJson ?? tpl?.templateJson ?? '').trim();
let engine: string | undefined;
if (templateJsonText) {
try {
engine = JSON.parse(templateJsonText)?.engine;
} catch {
createMessage.error('模板 JSON 无法解析,无法判断是否为原生模板');
return;
}
throw lastErr;
};
await navigateToDesignerPreview();
createMessage.success('正在打开设计器预览…');
quickPrintVisible.value = false;
}
if (engine !== 'native') {
createMessage.warning('快速打印仅支持「原生模板」engine 为 native请从列表预览 hiprint 模板或使用设计器');
return;
}
nativeListPreviewTemplateId.value = id;
nativeListPreviewOpen.value = true;
quickNativePrintOpen.value = false;
} catch (error: any) {
sessionStorage.removeItem(QUICK_PRINT_PREVIEW_STORAGE_KEY);
createMessage.error(`无法打开预览:${error?.message || '未知错误'}`);
createMessage.error(`加载模板失败:${error?.message || '未知错误'}`);
} finally {
quickPreviewLoading.value = false;
quickNativePrintLoading.value = false;
}
}
@@ -1318,191 +1282,6 @@
</html>`;
}
async function handleQuickPrint() {
const templateCode = String(quickPrintForm.value.templateCode || '').trim();
if (!templateCode) {
createMessage.warning('请选择模板编号');
return;
}
const dataText = String(quickPrintForm.value.dataJson || '').trim();
if (!dataText) {
createMessage.warning('请先输入打印数据JSON');
return;
}
let dataJson: any;
try {
dataJson = JSON.parse(dataText);
} catch (error: any) {
createMessage.error(`打印数据JSON格式错误${error?.message || '未知错误'}`);
return;
}
quickPrintLoading.value = true;
try {
const printer = String(quickPrintForm.value.printerName || '').trim();
if (quickPrintMode.value === 'serverText') {
await directPrint({
templateCode,
printerName: printer,
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') {
await executePdfServerPrint({
templateCode,
printerName: printer,
dataJson,
fileName: `${templateCode}.pdf`,
});
createMessage.success('已提交PDF到后端打印');
} else {
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;
}
const runtimeTemplate = new hiprint.PrintTemplate({
template: templateJson,
});
if (quickPrintMode.value === 'lodopTemplate') {
try {
await ensureClodopScriptLoaded();
} catch (e: any) {
createMessage.error(
`无法连接 C-Lodop${e?.message || '加载 CLodopfuncs.js 失败'}。请确认本机服务已启动;若站点为 HTTPS需安装扩展版并在浏览器中访问一次 https://localhost.lodop.net:8443 信任证书。`,
);
return;
}
const lodop =
(typeof (window as any).getLodop === 'function' ? (window as any).getLodop() : null) ||
(window as any)?.LODOP ||
(window as any)?.CLODOP;
if (!lodop) {
createMessage.error('未检测到 LODOP/C-Lodop请先安装并启动后重试');
return;
}
const html = optimizeMergedHeaderHtml(await resolveTemplateHtml(runtimeTemplate, dataJson), templateJson);
if (!html) {
const proto = Object.getPrototypeOf(runtimeTemplate);
const methodNames = Object.getOwnPropertyNames(proto || {}).filter((name) => name !== 'constructor');
console.warn('[Lodop实验] 未拿到模板HTMLPrintTemplate methods:', methodNames);
createMessage.error('当前 hiprint 版本未导出可用 HTML暂无法走 Lodop 实验模式(可先用“按模板样式打印”)');
return;
}
const panel = Array.isArray(templateJson?.panels) && templateJson.panels.length ? templateJson.panels[0] : {};
const widthMm = Number(panel?.width || 0);
const heightMm = Number(panel?.height || 0);
lodop.PRINT_INIT(`QH-MES-${templateCode}`);
if (widthMm > 0 && heightMm > 0 && typeof lodop.SET_PRINT_PAGESIZE === 'function') {
lodop.SET_PRINT_PAGESIZE(1, Math.round(widthMm * 10), Math.round(heightMm * 10), '');
}
if (printer && printer !== '__system_default__' && typeof lodop.SET_PRINTER_INDEXA === 'function') {
lodop.SET_PRINTER_INDEXA(printer);
}
const docHtml = buildLodopDocumentHtml(html, widthMm > 0 ? widthMm : undefined, heightMm > 0 ? heightMm : undefined);
const printWidth = widthMm > 0 ? `${widthMm}mm` : 'RightMargin:0mm';
lodop.ADD_PRINT_HTM('0mm', '0mm', printWidth, 'BottomMargin:0mm', docHtml);
if (typeof lodop.SET_PRINT_MODE === 'function') {
// 关闭强制满宽,尽量保持模板原始列宽比例
lodop.SET_PRINT_MODE('FULL_WIDTH_FOR_OVERFLOW', false);
}
lodop.PRINT();
createMessage.success('已通过 Lodop 提交打印任务(实验模式)');
} else {
if (printer && printer !== '__system_default__') {
try {
runtimeTemplate.print(dataJson, { printer });
} catch (_error) {
runtimeTemplate.print(dataJson, { printerName: printer });
}
} else {
runtimeTemplate.print(dataJson);
}
createMessage.success('已按模板样式发起打印');
}
}
}
quickPrintVisible.value = false;
} catch (error: any) {
createMessage.error(`打印失败:${error?.message || '未知错误'}`);
} finally {
quickPrintLoading.value = false;
}
}
function handleCreateNative() {
openModal(true, { isUpdate: false, isNative: true });
}