Files
qhmes/jeecgboot-vue3/src/views/print/template/index.vue

1692 lines
64 KiB
Vue
Raw Normal View History

<template>
<div>
<BasicTable @register="registerTable" :rowSelection="rowSelection">
<template #tableTitle>
<a-space>
<a-select
v-model:value="selectedPrinterName"
:options="printerOptions"
style="width: 260px"
allow-clear
show-search
option-filter-prop="label"
placeholder="选择打印机(本地/网络)"
/>
<a-input
v-model:value="manualPrinterName"
style="width: 180px"
placeholder="手动输入打印机名称"
@pressEnter="addManualPrinter"
/>
<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>
<a-dropdown v-if="selectedRowKeys.length > 0">
<template #overlay>
<a-menu>
<a-menu-item key="1" @click="batchHandleDelete" v-auth="'print:template:delete'">
<Icon icon="ant-design:delete-outlined" />
删除
</a-menu-item>
</a-menu>
</template>
<a-button>
批量操作
<Icon icon="mdi:chevron-down" />
</a-button>
</a-dropdown>
</template>
<template #action="{ record }">
<TableAction :actions="getTableAction(record)" />
</template>
</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-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-select
v-model:value="quickPrintForm.templateCode"
:options="templateCodeOptions"
style="width: 280px"
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>
</a-modal>
<a-modal
v-model:open="skillConvertVisible"
:title="skillModalTitle"
width="960px"
:footer="null"
destroy-on-close
@cancel="resetSkillConvertState"
>
<a-space direction="vertical" style="width: 100%" size="middle">
<a-alert type="info" show-icon message="流程说明" :description="skillModalAlertDescription" />
<a-row :gutter="12">
<a-col :span="10">
<div class="skill-field-label">模板支持编号/名称搜索</div>
<a-input
v-model:value="skillTemplateSearch"
allow-clear
placeholder="输入关键字过滤模板列表"
style="margin-bottom: 8px"
/>
<a-select
v-model:value="skillConvertForm.templateCode"
:options="skillTemplateFilteredOptions"
style="width: 100%"
show-search
:filter-option="false"
option-filter-prop="label"
placeholder="请选择模板编号"
/>
</a-col>
<a-col :span="14">
<div class="skill-field-label">打印机</div>
<a-select
v-model:value="skillConvertForm.printerName"
:options="printerOptions"
style="width: 100%"
show-search
allow-clear
option-filter-prop="label"
placeholder="可为空使用系统默认打印机"
/>
</a-col>
</a-row>
<div>
<div class="skill-field-label">打印数据 JSON</div>
<a-textarea v-model:value="skillConvertForm.dataJson" :rows="10" :placeholder="skillDataJsonPlaceholder" />
<div v-if="skillJsonError" class="skill-json-error">{{ skillJsonError }}</div>
<a-space style="margin-top: 8px" wrap>
<a-button size="small" @click="handleSkillInsertExample">填入示例</a-button>
<a-button size="small" @click="handleSkillFormatDataJson">格式化 JSON</a-button>
<a-button size="small" type="primary" ghost @click="handleSkillValidateJson">校验 JSON</a-button>
<a-button size="small" :loading="skillPreviewLoading" @click="handleSkillPreview">生成预览</a-button>
<a-button size="small" type="primary" :loading="skillPrintLoading" @click="handleSkillSubmitPrint">提交后端 PDF 打印</a-button>
</a-space>
</div>
<div>
<div class="skill-field-label">预览 Lodop 包装一致的 HTML供确认表头合并与纸张</div>
<a-spin :spinning="skillPreviewLoading">
<div class="skill-preview-wrap">
<iframe v-if="skillPreviewSrcdoc" class="skill-preview-iframe" :title="skillPreviewIframeTitle" :srcdoc="skillPreviewSrcdoc" />
<a-empty v-else description="请先选择模板并点击「生成预览」" />
</div>
</a-spin>
</div>
</a-space>
</a-modal>
</div>
</template>
<script lang="ts" name="PrintTemplateList" setup>
import { computed, onMounted, ref, watch } from 'vue';
import 'vue-plugin-hiprint/dist/print-lock.css';
import { Icon } from '/@/components/Icon';
import { BasicTable, TableAction } from '/@/components/Table';
import { useModal } from '/@/components/Modal';
import { useListPage } from '/@/hooks/system/useListPage';
import { useMessage } from '/@/hooks/web/useMessage';
import { useRouter } from 'vue-router';
import { columns, searchFormSchema } from './printTemplate.data';
import {
list,
add,
deleteOne,
batchDelete,
queryPrinters,
directPrint,
directPrintPdf,
queryByCode,
queryById,
} from './printTemplate.api';
import { ensureClodopScriptLoaded } from './lodopLoader';
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 {
fetchPrintDotPrinters,
getPrintDotBridgeConfig,
printDotSendPdf,
resolvePrintDotPrinterName,
setPrintDotBridgeConfig,
} from './utils/printDotBridge';
import { normalizeImportedNativeSchema } from './native/core/nativeSchemaNormalize';
import { renderNativePrintHtml } from './native/core/printRenderer';
defineOptions({ name: 'PrintTemplateList' });
/** 原生模板列表「预览」弹窗 */
const nativeListPreviewOpen = ref(false);
const nativeListPreviewTemplateId = ref<string | null>(null);
const router = useRouter();
const { createMessage } = useMessage();
const [registerModal, { openModal }] = useModal();
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 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();
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",
"mainTable": [
{ "materialCode": "M01", "materialName": "示例物料", "qty": 10 }
]
}`;
const skillDataJsonPlaceholder = `请填写对象或数组形式的打印数据,例如:\n${SKILL_DATA_JSON_EXAMPLE}`;
const skillConvertVisible = ref(false);
const skillConvertForm = ref<{ templateCode: string; printerName?: string; dataJson: string }>({
templateCode: '',
printerName: '__system_default__',
dataJson: SKILL_DATA_JSON_EXAMPLE,
});
const skillTemplateSearch = ref('');
const skillPreviewSrcdoc = ref('');
const skillPreviewLoading = ref(false);
const skillPrintLoading = ref(false);
const skillJsonError = ref('');
/** convert原「技能转换打印」guide对齐仓库 Cursor 技能 hiprint-export-print */
const skillPrintModalMode = ref<'convert' | 'guide'>('convert');
const skillModalTitle = computed(() =>
skillPrintModalMode.value === 'guide'
? '技能指南打印hiprint-export-print'
: '技能转换打印hiprint → PDF → 后端队列)',
);
const skillModalAlertDescription = computed(() =>
skillPrintModalMode.value === 'guide'
? '与项目 Cursor 技能「hiprint-export-print」约定一致hiprint 模板 JSON + 打印数据 JSON → 校验 → 生成预览Lodop 同源 HTML 包装)→ html2canvas + jsPDF 生成 PDF → 调用后端 directPrintPdf。开发人员可查阅仓库 .cursor/skills/hiprint-export-print/SKILL.md 中的文件路径、自检项与常见问题。'
: '选择模板并填写打印数据 JSON → 校验 → 生成预览确认版式 → 提交为 PDF 由后端发送到所选打印机。',
);
const skillPreviewIframeTitle = computed(() =>
skillPrintModalMode.value === 'guide' ? '技能指南打印预览' : '技能转换预览',
);
const skillTemplateFilteredOptions = computed(() => {
const keyword = skillTemplateSearch.value.trim().toLowerCase();
const all = templateCodeOptions.value;
if (!keyword) {
return all;
}
return all.filter(
(item) =>
String(item.value || '')
.toLowerCase()
.includes(keyword) || String(item.label || '').toLowerCase().includes(keyword),
);
});
let hiprint: any = null;
const PRINTER_STORAGE_KEY = 'print_template_selected_printer';
function resolveHiprint(module: any) {
const defaultExport = module?.default || {};
hiprint = module?.hiprint || defaultExport?.hiprint || (window as any)?.hiprint;
return hiprint;
}
async function initHiprintForQuickPrint() {
if (hiprint) {
return;
}
const module = await import('vue-plugin-hiprint');
const hp = resolveHiprint(module);
if (!hp) {
throw new Error('未获取到 hiprint 实例');
}
hp.init({ providers: resolveProviders(module) });
}
const { tableContext } = useListPage({
tableProps: {
title: '打印模板',
api: list,
columns,
rowKey: 'id',
formConfig: { schemas: searchFormSchema },
actionColumn: { width: 300 },
},
});
const [registerTable, { reload }, { rowSelection, selectedRowKeys }] = tableContext;
async function refreshPrinterOptions(showMessage = true) {
const payload = (await queryPrinters()) as Record<string, any>;
const names = [
...(Array.isArray(payload?.serverPrinters) ? payload.serverPrinters : []),
...(Array.isArray(payload?.networkPrinters) ? payload.networkPrinters : []),
]
.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 });
});
if (selectedPrinterName.value && !optionMap.has(selectedPrinterName.value)) {
optionMap.set(selectedPrinterName.value, {
label: `${selectedPrinterName.value}(手动)`,
value: selectedPrinterName.value,
});
}
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) {
const reason = String(payload?.capability?.localReason || '').trim();
createMessage.warning(`服务端未返回可用打印机。${reason || '请在后端配置网络打印机后重试。'}`);
}
}
}
function addManualPrinter() {
const name = String(manualPrinterName.value || '').trim();
if (!name) {
return;
}
const exists = printerOptions.value.some((item) => item.value === name);
if (!exists) {
printerOptions.value = [...printerOptions.value, { label: `${name}(手动)`, value: name }];
}
selectedPrinterName.value = name;
manualPrinterName.value = '';
createMessage.success('已添加手动打印机名称');
}
async function loadTemplateCodeOptions() {
const pageData = (await list({ pageNo: 1, pageSize: 500 })) as Record<string, any>;
const records = (pageData?.records || pageData?.result?.records || []) as Record<string, any>[];
templateCodeOptions.value = records
.map((item) => ({
value: String(item?.templateCode || '').trim(),
label: `${String(item?.templateCode || '').trim()} ${item?.templateName ? `- ${item.templateName}` : ''}`.trim(),
}))
.filter((item) => !!item.value);
}
async function openQuickPrintModal() {
quickPrintForm.value.printerName = selectedPrinterName.value || '__system_default__';
quickPrintMode.value = 'templateStyle';
if (!templateCodeOptions.value.length) {
await loadTemplateCodeOptions();
}
quickPrintVisible.value = true;
}
/**
* 跳转打印设计器并自动调用与设计器预览相同的 previewTemplate 逻辑 PrintDesigner runQuickPrintPreviewFromSessionStorage
*/
async function handleQuickPrintDesignerPreview() {
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;
}
try {
JSON.parse(dataText);
} catch (error: any) {
createMessage.error(`打印数据JSON格式错误${error?.message || '未知错误'}`);
return;
}
quickPreviewLoading.value = true;
try {
const tpl = (await queryByCode(templateCode)) as Record<string, any>;
const id = String(tpl?.id ?? (tpl as any)?.result?.id ?? '').trim();
if (!id) {
createMessage.error(`未找到模板记录主键,无法打开设计器预览。返回字段:${Object.keys(tpl || {}).join(', ') || '空'}`);
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;
}
}
}
throw lastErr;
};
await navigateToDesignerPreview();
createMessage.success('正在打开设计器预览…');
quickPrintVisible.value = false;
} catch (error: any) {
sessionStorage.removeItem(QUICK_PRINT_PREVIEW_STORAGE_KEY);
createMessage.error(`无法打开预览:${error?.message || '未知错误'}`);
} finally {
quickPreviewLoading.value = false;
}
}
function resetSkillConvertState() {
skillPreviewSrcdoc.value = '';
skillJsonError.value = '';
skillTemplateSearch.value = '';
}
function handleSkillInsertExample() {
skillConvertForm.value.dataJson = SKILL_DATA_JSON_EXAMPLE;
skillJsonError.value = '';
createMessage.success('已填入示例 JSON');
}
function handleSkillFormatDataJson() {
const text = String(skillConvertForm.value.dataJson || '').trim();
if (!text) {
createMessage.warning('请先输入 JSON');
return;
}
try {
skillConvertForm.value.dataJson = JSON.stringify(JSON.parse(text), null, 2);
skillJsonError.value = '';
createMessage.success('已格式化');
} catch (error: any) {
skillJsonError.value = error?.message || 'JSON 格式错误';
createMessage.error(`无法格式化:${skillJsonError.value}`);
}
}
/** silent=true 时不弹出“格式正确”提示,供预览/打印前校验 */
function validateSkillDataJsonInternal(silent: boolean): boolean {
const text = String(skillConvertForm.value.dataJson || '').trim();
if (!text) {
skillJsonError.value = '内容为空';
createMessage.warning('请先输入打印数据 JSON');
return false;
}
try {
JSON.parse(text);
skillJsonError.value = '';
if (!silent) {
createMessage.success('JSON 格式正确');
}
return true;
} catch (error: any) {
skillJsonError.value = error?.message || 'JSON 解析失败';
createMessage.error(`校验失败:${skillJsonError.value}`);
return false;
}
}
function handleSkillValidateJson() {
validateSkillDataJsonInternal(false);
}
/** hiprint 渲染 HTML → html2canvas → jsPDF → 提交后端 directPrintPdf */
async function executePdfServerPrint(params: {
templateCode: string;
printerName?: string;
dataJson: any;
fileName?: string;
}) {
const templateCode = String(params.templateCode || '').trim();
await initHiprintForQuickPrint();
const tplData = (await queryByCode(templateCode)) as Record<string, any>;
const templateJsonText = String(tplData?.templateJson || '').trim();
if (!templateJsonText) {
throw new Error('模板JSON为空无法生成 PDF');
}
let templateJson: any;
try {
templateJson = JSON.parse(templateJsonText);
} catch (error: any) {
throw new Error(`模板JSON格式错误${error?.message || '未知错误'}`);
}
const runtimeTemplate = new hiprint.PrintTemplate({
template: templateJson,
});
const html = optimizeMergedHeaderHtml(await resolveTemplateHtml(runtimeTemplate, params.dataJson), templateJson);
if (!html) {
throw new Error('当前模板未生成可用预览内容,无法转 PDF');
}
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 buildPdfBase64FromHtmlFragment(html, widthMm, heightMm);
const printer = String(params.printerName || '').trim();
await directPrintPdf({
templateCode,
printerName: printer,
dataJson: params.dataJson,
pdfBase64,
fileName: params.fileName || `${templateCode}.pdf`,
});
}
async function handleSkillPreview() {
const templateCode = String(skillConvertForm.value.templateCode || '').trim();
if (!templateCode) {
createMessage.warning('请先选择模板');
return;
}
if (!validateSkillDataJsonInternal(true)) {
return;
}
let dataJson: any;
try {
dataJson = JSON.parse(String(skillConvertForm.value.dataJson || '').trim());
} catch {
return;
}
skillPreviewLoading.value = true;
try {
await initHiprintForQuickPrint();
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,
});
const html = optimizeMergedHeaderHtml(await resolveTemplateHtml(runtimeTemplate, dataJson), templateJson);
if (!html) {
createMessage.error('未能从 hiprint 生成预览 HTML请检查模板与数据字段是否匹配');
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);
skillPreviewSrcdoc.value = buildLodopDocumentHtml(html, widthMm > 0 ? widthMm : undefined, heightMm > 0 ? heightMm : undefined);
createMessage.success('预览已生成');
} catch (error: any) {
createMessage.error(`预览失败:${error?.message || '未知错误'}`);
} finally {
skillPreviewLoading.value = false;
}
}
async function handleSkillSubmitPrint() {
const templateCode = String(skillConvertForm.value.templateCode || '').trim();
if (!templateCode) {
createMessage.warning('请先选择模板');
return;
}
if (!validateSkillDataJsonInternal(true)) {
return;
}
let dataJson: any;
try {
dataJson = JSON.parse(String(skillConvertForm.value.dataJson || '').trim());
} catch {
return;
}
skillPrintLoading.value = true;
try {
await executePdfServerPrint({
templateCode,
printerName: skillConvertForm.value.printerName,
dataJson,
fileName:
skillPrintModalMode.value === 'guide' ? `${templateCode}-hiprint-export-print.pdf` : `${templateCode}-skill.pdf`,
});
createMessage.success('已提交 PDF 到后端打印队列');
skillConvertVisible.value = false;
} catch (error: any) {
createMessage.error(`提交失败:${error?.message || '未知错误'}`);
} finally {
skillPrintLoading.value = false;
}
}
function normalizeHtmlOutput(value: any): string {
if (!value) {
return '';
}
if (typeof value === 'string') {
return value.trim();
}
if (Array.isArray(value)) {
return value
.map((item) => normalizeHtmlOutput(item))
.filter(Boolean)
.join('');
}
if (value instanceof HTMLElement) {
return value.outerHTML || value.innerHTML || '';
}
if (value && typeof value === 'object') {
// 兼容 jQuery 对象:优先取第一个元素的 HTML
const first = (value as any)[0];
if (first instanceof HTMLElement) {
return first.outerHTML || first.innerHTML || '';
}
if (typeof (value as any).html === 'function') {
try {
const htmlValue = (value as any).html();
return typeof htmlValue === 'string' ? htmlValue.trim() : '';
} catch (_error) {
// ignore
}
}
if (typeof (value as any).outerHTML === 'string') {
return String((value as any).outerHTML).trim();
}
if (typeof (value as any).innerHTML === 'string') {
return String((value as any).innerHTML).trim();
}
}
return '';
}
async function resolveTemplateHtml(runtimeTemplate: any, dataJson: any): Promise<string> {
const buildDataCandidates = (rawData: any): any[] => {
const candidates: any[] = [rawData];
if (Array.isArray(rawData)) {
candidates.push({
detailList: rawData,
mainTable: rawData,
list: rawData,
items: rawData,
dataList: rawData,
});
return candidates;
}
if (rawData && typeof rawData === 'object') {
const objectData = rawData as Record<string, any>;
const firstArrayKey = Object.keys(objectData).find((key) => Array.isArray(objectData[key]));
if (firstArrayKey) {
const arr = objectData[firstArrayKey];
candidates.push({
...objectData,
detailList: objectData.detailList ?? arr,
mainTable: objectData.mainTable ?? arr,
list: objectData.list ?? arr,
items: objectData.items ?? arr,
dataList: objectData.dataList ?? arr,
});
}
}
return candidates;
};
const fixedCandidates = ['getHtml', 'toHtml', 'getHtmlByData'];
const dataCandidates = buildDataCandidates(dataJson);
for (const methodName of fixedCandidates) {
const fn = runtimeTemplate?.[methodName];
if (typeof fn !== 'function') {
continue;
}
for (const dataItem of dataCandidates) {
try {
const maybeValue = await Promise.resolve(fn.call(runtimeTemplate, dataItem));
const html = normalizeHtmlOutput(maybeValue);
if (html) {
return html;
}
} catch (_error) {
// ignore
}
}
}
// 兜底:扫描原型上的潜在导出方法(不同版本命名不一致)
const proto = Object.getPrototypeOf(runtimeTemplate);
const methodNames = Object.getOwnPropertyNames(proto || {})
.filter((name) => name !== 'constructor')
.filter((name) => /html|preview|content/i.test(name))
.filter((name) => !/^print$/i.test(name));
for (const methodName of methodNames) {
const fn = runtimeTemplate?.[methodName];
if (typeof fn !== 'function') {
continue;
}
for (const dataItem of dataCandidates) {
const argSets = [[dataItem], [dataItem, {}], []] as any[][];
for (const args of argSets) {
try {
const maybeValue = await Promise.resolve(fn.apply(runtimeTemplate, args));
const html = normalizeHtmlOutput(maybeValue);
if (html) {
return html;
}
} catch (_error) {
// ignore
}
}
}
}
return '';
}
function normalizeHeaderRowsFromTemplate(options: Record<string, any>): any[][] {
const candidates = [options?.headerColumns, options?.columns, options?.tableColumns];
for (const candidate of candidates) {
if (!Array.isArray(candidate) || !candidate.length) {
continue;
}
if (Array.isArray(candidate[0])) {
return candidate.map((row: any) => (Array.isArray(row) ? row : []));
}
return [candidate];
}
return [];
}
function resolveTemplateHeaderMeta(templateJson?: any): { rows: any[][]; options: Record<string, any> | null } {
const panels = Array.isArray(templateJson?.panels) ? templateJson.panels : [];
for (const panel of panels) {
const printElements = Array.isArray(panel?.printElements) ? panel.printElements : [];
for (const element of printElements) {
const options = element?.options || {};
const rows = normalizeHeaderRowsFromTemplate(options);
if (rows.length) {
return { rows, options };
}
}
}
return { rows: [], options: null };
}
/** 与 PrintDesigner 一致:兼容 {{field}} / item.xxx */
function normalizeBindField(raw: unknown) {
let field = String(raw ?? '').trim();
if (!field) {
return '';
}
const mustacheMatch = field.match(/^\{\{\s*([^}]+)\s*\}\}$/);
if (mustacheMatch?.[1]) {
field = mustacheMatch[1].trim();
}
field = field.replace(/^item\./i, '').trim();
return field;
}
/**
* PrintDesigner.resolveBodyColumnsByHeaderRows 一致按表头网格得到叶子列顺序含无 field 的占位列如行号
*/
function resolveBodyColumnsByHeaderRows(headerRows: any[]): any[] {
if (!Array.isArray(headerRows) || !headerRows.length) {
return [];
}
const rowCount = headerRows.length;
let totalCols = 0;
headerRows.forEach((row) => {
const width = (Array.isArray(row) ? row : []).reduce(
(sum: number, cell: any) => sum + (Number(cell?.colspan || cell?.colSpan || 1) || 1),
0,
);
totalCols = Math.max(totalCols, width);
});
if (!totalCols) {
return [];
}
const grid: any[][] = Array.from({ length: rowCount }, () => Array.from({ length: totalCols }, () => null));
for (let r = 0; r < rowCount; r += 1) {
const row = Array.isArray(headerRows[r]) ? headerRows[r] : [];
let c = 0;
row.forEach((cell: any) => {
while (c < totalCols && grid[r][c]) {
c += 1;
}
const rowspan = Number(cell?.rowspan || cell?.rowSpan || 1) || 1;
const colspan = Number(cell?.colspan || cell?.colSpan || 1) || 1;
for (let rr = r; rr < Math.min(rowCount, r + rowspan); rr += 1) {
for (let cc = c; cc < Math.min(totalCols, c + colspan); cc += 1) {
grid[rr][cc] = cell;
}
}
c += colspan;
});
}
const result: any[] = [];
for (let c = 0; c < totalCols; c += 1) {
let hitCell: any = null;
for (let r = rowCount - 1; r >= 0; r -= 1) {
const cell = grid[r][c];
const field = cell?.field || cell?.dataIndex || cell?.key || cell?.name || '';
if (field) {
hitCell = cell;
break;
}
}
if (!hitCell) {
result.push({ field: '', title: '', align: '', halign: '', width: '', autoWrap: false });
continue;
}
const field = normalizeBindField(hitCell?.field || hitCell?.dataIndex || hitCell?.key || hitCell?.name || '');
const align = String(hitCell?.align || '');
const halign = String(hitCell?.halign || hitCell?.align || '');
const width = hitCell?.width || hitCell?.minWidth || '';
const autoWrap = Boolean(hitCell?.autoWrap ?? hitCell?.tableAutoWrap ?? hitCell?.wrap ?? hitCell?.wordWrap ?? false);
result.push({ field, title: hitCell?.title || '', align, halign, width, autoWrap });
}
return result;
}
function resolveBodyColumnsFromTemplateOptions(options: Record<string, any>): any[] {
const headerRows = normalizeHeaderRowsFromTemplate(options);
if (headerRows.length) {
return resolveBodyColumnsByHeaderRows(headerRows);
}
if (Array.isArray(options?.fields) && options.fields.length) {
return options.fields.map((item: any) =>
typeof item === 'string'
? { field: normalizeBindField(item), title: item, align: '', halign: '', width: '', autoWrap: false }
: {
field: normalizeBindField(item?.field || item?.dataIndex || item?.key || ''),
title: item?.title || item?.text || item?.label || '',
width: item?.width || item?.columnWidth || item?.w || '',
align: item?.align || item?.halign || '',
halign: item?.halign || item?.align || '',
autoWrap: item?.autoWrap ?? item?.tableAutoWrap ?? item?.wrap ?? item?.wordWrap,
},
);
}
return [];
}
function applyBodyColumnMetaToTable(table: HTMLTableElement, options: Record<string, any>) {
const tbodyRow = table.querySelector('tbody tr');
if (!tbodyRow) {
return;
}
const bodyCellCount = tbodyRow.querySelectorAll('td').length;
if (!bodyCellCount) {
return;
}
let bodyColumns = resolveBodyColumnsFromTemplateOptions(options);
if (!bodyColumns.length) {
return;
}
if (bodyColumns.length > bodyCellCount) {
bodyColumns = bodyColumns.slice(0, bodyCellCount);
} else if (bodyColumns.length < bodyCellCount) {
const pad = bodyCellCount - bodyColumns.length;
for (let i = 0; i < pad; i += 1) {
bodyColumns.push({ field: '', title: '', align: '', halign: '', width: '', autoWrap: false });
}
}
const tableWidthPx = Number(options?.width || 0);
const rawWidths = bodyColumns.map((colMeta) => {
const w = Number(colMeta?.width || 0);
return Number.isFinite(w) && w > 0 ? w : 0;
});
const definedWidths = rawWidths.filter((v) => v > 0);
const avgWidth = definedWidths.length
? definedWidths.reduce((sum, v) => sum + v, 0) / definedWidths.length
: tableWidthPx > 0
? tableWidthPx / Math.max(1, bodyColumns.length)
: 100;
const effectiveWidths = rawWidths.map((v) => (v > 0 ? v : avgWidth));
const totalEffectiveWidth = effectiveWidths.reduce((sum, v) => sum + v, 0);
table.style.tableLayout = 'fixed';
table.style.width = '100%';
const colgroup = document.createElement('colgroup');
effectiveWidths.forEach((w) => {
const col = document.createElement('col');
const pct = totalEffectiveWidth > 0 ? ((w / totalEffectiveWidth) * 100).toFixed(6) : '';
col.setAttribute('style', pct ? `width:${pct}%;` : '');
colgroup.appendChild(col);
});
const old = table.querySelector('colgroup');
if (old) {
old.replaceWith(colgroup);
} else if (table.firstChild) {
table.insertBefore(colgroup, table.firstChild);
} else {
table.appendChild(colgroup);
}
const bodyRows = Array.from(table.querySelectorAll('tbody tr'));
bodyRows.forEach((tr) => {
const cells = Array.from(tr.querySelectorAll('td')) as HTMLTableCellElement[];
cells.forEach((td, idx) => {
const col = bodyColumns[idx];
if (!col) {
return;
}
const wrap = Boolean(col?.autoWrap);
const align = String(col?.align || col?.halign || '').trim();
if (wrap) {
td.style.whiteSpace = 'normal';
td.style.wordBreak = 'break-word';
td.style.overflowWrap = 'break-word';
td.style.lineHeight = td.style.lineHeight || '1.2';
} else {
td.style.whiteSpace = 'nowrap';
td.style.wordBreak = 'normal';
td.style.overflowWrap = 'normal';
}
if (align) {
td.style.textAlign = align;
}
});
});
}
function resolveHeaderRowHeight(options: Record<string, any>): string {
const candidates = [
options?.headerRowHeight,
options?.tableHeaderRowHeight,
options?.thRowHeight,
options?.rowHeight,
];
for (const value of candidates) {
if (value === null || value === undefined || value === '') {
continue;
}
const text = String(value).trim();
if (!text) {
continue;
}
return /\d$/.test(text) ? `${text}px` : text;
}
return '';
}
function buildHeaderThStyle(cell: any, options: Record<string, any>, rowHeight: string): string {
const headerBg =
options?.headerBackground ||
options?.tableHeaderBackground ||
options?.headerBg ||
options?.thBackground ||
'';
const headerFontSize = options?.headerFontSize || options?.thFontSize || '';
const headerLineHeight = options?.headerLineHeight || options?.thLineHeight || '';
const headerFontWeight = options?.headerFontWeight || options?.thFontWeight || '600';
const headerFontFamily = options?.headerFontFamily || options?.thFontFamily || '';
const headerAlign = String(cell?.halign || cell?.align || options?.headerAlign || 'center');
const headerAutoWrap = Boolean(cell?.autoWrap ?? cell?.tableAutoWrap ?? cell?.wrap ?? cell?.wordWrap ?? false);
return [
'border:1px solid #333',
'padding:2px 4px',
`text-align:${headerAlign}`,
'vertical-align:middle',
headerAutoWrap ? 'white-space:normal' : 'white-space:nowrap',
headerAutoWrap ? 'word-break:break-all' : '',
headerAutoWrap ? 'overflow-wrap:anywhere' : '',
`font-weight:${headerFontWeight}`,
headerFontFamily ? `font-family:${headerFontFamily}` : '',
headerBg ? `background:${headerBg}` : '',
headerFontSize ? `font-size:${headerFontSize}` : '',
headerLineHeight ? `line-height:${headerLineHeight}` : '',
rowHeight ? `height:${rowHeight}` : '',
rowHeight ? `min-height:${rowHeight}` : '',
!headerLineHeight && rowHeight ? `line-height:${rowHeight}` : '',
'print-color-adjust:exact',
'-webkit-print-color-adjust:exact',
]
.filter(Boolean)
.join(';');
}
function optimizeMergedHeaderHtml(sourceHtml: string, templateJson?: any): string {
if (!sourceHtml || !sourceHtml.includes('<table')) {
return sourceHtml;
}
const wrapper = document.createElement('div');
wrapper.innerHTML = sourceHtml;
const templateHeaderMeta = resolveTemplateHeaderMeta(templateJson);
const tables = Array.from(wrapper.querySelectorAll('table'));
tables.forEach((table) => {
const buildTemplateHeader = () => {
if (!templateHeaderMeta.rows.length || !templateHeaderMeta.options) {
return null;
}
const rowHeight = resolveHeaderRowHeight(templateHeaderMeta.options);
const thead = document.createElement('thead');
templateHeaderMeta.rows.forEach((row) => {
const tr = document.createElement('tr');
if (rowHeight) {
tr.setAttribute('style', `height:${rowHeight};min-height:${rowHeight};`);
}
row.forEach((cell) => {
const th = document.createElement('th');
const rowspan = Math.max(1, Number(cell?.rowspan || cell?.rowSpan || 1));
const colspan = Math.max(1, Number(cell?.colspan || cell?.colSpan || 1));
if (rowspan > 1) {
th.rowSpan = rowspan;
}
if (colspan > 1) {
th.colSpan = colspan;
}
th.innerHTML = String(cell?.title || cell?.text || cell?.label || '').trim();
th.setAttribute('style', buildHeaderThStyle(cell, templateHeaderMeta.options as Record<string, any>, rowHeight));
tr.appendChild(th);
});
thead.appendChild(tr);
});
return thead;
};
const injectedThead = buildTemplateHeader();
if (injectedThead) {
const oldThead = table.querySelector('thead');
if (oldThead) {
oldThead.replaceWith(injectedThead);
} else {
const allRows = Array.from(table.querySelectorAll('tr'));
const maybeHeaderRows = allRows.slice(0, templateHeaderMeta.rows.length);
maybeHeaderRows.forEach((row) => row.remove());
if (table.firstChild) {
table.insertBefore(injectedThead, table.firstChild);
} else {
table.appendChild(injectedThead);
}
}
}
if (templateHeaderMeta.options) {
applyBodyColumnMetaToTable(table as HTMLTableElement, templateHeaderMeta.options as Record<string, any>);
}
const detectHeaderRows = () => {
const thead = table.querySelector('thead');
if (thead) {
const headRows = Array.from(thead.querySelectorAll('tr'));
if (headRows.length) {
return headRows;
}
}
const allRows = Array.from(table.querySelectorAll('tr'));
const result: HTMLTableRowElement[] = [];
for (const row of allRows) {
const cells = Array.from(row.querySelectorAll('th,td')) as HTMLTableCellElement[];
if (!cells.length) {
continue;
}
const firstText = String(cells[0]?.textContent || '')
.replace(/\u00a0/g, '')
.replace(/\s+/g, '')
.trim();
// 命中明细行(首列纯数字)即停止,前面都视为表头区域
if (/^\d+$/.test(firstText)) {
break;
}
result.push(row as HTMLTableRowElement);
if (result.length >= 6) {
break;
}
}
return result;
};
const rows = detectHeaderRows();
if (rows.length < 2) {
return;
}
const normalizeHeaderText = (cell: HTMLTableCellElement | null | undefined) =>
String(cell?.textContent || '')
.replace(/\u00a0/g, '')
.replace(/\s+/g, '')
.trim();
type HeaderCellInfo = { el: HTMLTableCellElement; row: number; col: number; rowSpan: number; colSpan: number };
const matrix: Array<Array<HeaderCellInfo | null>> = [];
rows.forEach((row, rowIdx) => {
matrix[rowIdx] = matrix[rowIdx] || [];
let colIdx = 0;
const cells = Array.from(row.querySelectorAll('th,td')) as HTMLTableCellElement[];
cells.forEach((cell) => {
while (matrix[rowIdx][colIdx]) {
colIdx += 1;
}
const rowSpan = Math.max(1, Number(cell.getAttribute('rowspan') || 1));
const colSpan = Math.max(1, Number(cell.getAttribute('colspan') || 1));
const info: HeaderCellInfo = { el: cell, row: rowIdx, col: colIdx, rowSpan, colSpan };
for (let r = rowIdx; r < rowIdx + rowSpan; r += 1) {
matrix[r] = matrix[r] || [];
for (let c = colIdx; c < colIdx + colSpan; c += 1) {
matrix[r][c] = info;
}
}
colIdx += colSpan;
});
});
// 处理“上一行应 rowspan但下一行是空占位或同名重复”的情况
for (let r = 0; r < rows.length - 1; r += 1) {
const currentRow = matrix[r] || [];
const nextRow = matrix[r + 1] || [];
for (let c = 0; c < currentRow.length; c += 1) {
const info = currentRow[c];
if (!info || info.row !== r || info.col !== c || info.rowSpan > 1) {
continue;
}
const currentText = normalizeHeaderText(info.el);
if (!currentText) {
continue;
}
const mergeCells: HTMLTableCellElement[] = [];
let canMergeDown = true;
for (let k = 0; k < info.colSpan; k += 1) {
const below = nextRow[c + k];
if (!below || below.row !== r + 1 || below.col !== c + k || below.colSpan > 1 || below.rowSpan > 1) {
canMergeDown = false;
break;
}
const belowText = normalizeHeaderText(below.el);
const isPlaceholder = !belowText || belowText === '-';
const isSameTitle = !!currentText && belowText === currentText;
if (!isPlaceholder && !isSameTitle) {
canMergeDown = false;
break;
}
mergeCells.push(below.el);
}
if (canMergeDown && mergeCells.length) {
const nextRowSpan = Math.max(1, Number(info.el.getAttribute('rowspan') || 1));
info.el.setAttribute('rowspan', String(nextRowSpan + 1));
info.el.classList.add('qh-merged-header-cell');
info.el.style.borderBottom = 'none';
mergeCells.forEach((el) => {
el.style.borderTop = 'none';
el.remove();
});
}
}
}
});
return wrapper.innerHTML;
}
function buildLodopDocumentHtml(html: string, widthMm?: number, heightMm?: number): string {
const styleTexts = '';
// 仅保留 hiprint 打印相关样式,避免把业务页面全局样式带入后污染列宽计算
const stylesheetLinks = Array.from(document.querySelectorAll('link[rel="stylesheet"]'))
.map((node) => (node as HTMLLinkElement).href)
.filter(Boolean)
.filter((href) => /print-lock|hiprint/i.test(href))
.map((href) => `<link rel="stylesheet" href="${href}" />`)
.join('\n');
const pageCss =
widthMm && heightMm
? `@page { size: ${widthMm}mm ${heightMm}mm; margin: 0; }`
: '@page { margin: 0; }';
const rootSizeCss =
widthMm && heightMm
? `.lodop-print-root { width: ${widthMm}mm; margin: 0; padding: 0; overflow: visible; }`
: '.lodop-print-root { margin: 0; padding: 0; overflow: visible; }';
const compatCss = `
html, body { margin: 0; padding: 0; background: #fff; }
${rootSizeCss}
.hiprint-printTemplate {
transform: none !important;
transform-origin: top left !important;
zoom: 1 !important;
-webkit-print-color-adjust: exact !important;
print-color-adjust: exact !important;
}
/* 打印态去掉设计器预览缩放,避免整体变小与合并表头被裁切 */
.hiprint-printTemplate [style*="transform: scale"] {
transform: none !important;
}
.hiprint-printTemplate table {
border-collapse: collapse !important;
border-spacing: 0 !important;
}
.hiprint-printTemplate th,
.hiprint-printTemplate td {
border-color: #333 !important;
border-style: solid !important;
border-width: 1px !important;
overflow: visible !important;
text-overflow: clip !important;
white-space: normal !important;
vertical-align: middle !important;
word-break: normal !important;
line-height: 1.35 !important;
}
.hiprint-printTemplate thead th {
line-height: 1.45 !important;
padding-top: 2px !important;
padding-bottom: 2px !important;
}
.hiprint-printTemplate .qh-merged-header-cell {
border-bottom: none !important;
}
.hiprint-printTemplate .hiprint-printElement,
.hiprint-printTemplate .hiprint-printElement > div,
.hiprint-printTemplate .hiprint-printElement span,
.hiprint-printTemplate .hiprint-printElement p {
overflow: visible !important;
text-overflow: clip !important;
}
/* 合并单元格专门处理:避免跨列跨行时边框与文字被裁切 */
.hiprint-printTemplate th[colspan], .hiprint-printTemplate td[colspan],
.hiprint-printTemplate th[rowspan], .hiprint-printTemplate td[rowspan] {
overflow: visible !important;
white-space: normal !important;
text-align: center !important;
vertical-align: middle !important;
position: relative;
z-index: 1;
background-clip: padding-box !important;
}
.hiprint-printTemplate tr {
page-break-inside: avoid !important;
}
.hiprint-printTemplate img, .hiprint-printTemplate canvas {
max-width: none !important;
max-height: none !important;
}
`;
return `<!doctype html>
<html>
<head>
<meta charset="UTF-8" />
${stylesheetLinks}
<style>${styleTexts}</style>
<style>${pageCss}\n${compatCss}</style>
</head>
<body><div class="lodop-print-root">${html}</div></body>
</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 });
}
function handleEdit(record: Recordable) {
openModal(true, { isUpdate: true, record });
}
function isNativeTemplate(record: Recordable) {
const raw = record?.templateJson;
if (!raw) {
return false;
}
if (typeof raw === 'object') {
return raw?.engine === 'native';
}
if (typeof raw !== 'string') {
return false;
}
try {
const parsed = JSON.parse(raw);
return parsed?.engine === 'native';
} catch (_error) {
return false;
}
}
function handleDesign(record: Recordable) {
const path = isNativeTemplate(record) ? '/print/native-designer' : '/print/designer';
router.push({ path, query: { id: record.id } });
}
function handleNativeListPreview(record: Recordable) {
nativeListPreviewTemplateId.value = String(record.id || '');
nativeListPreviewOpen.value = true;
}
async function handleDelete(record: Recordable) {
await deleteOne({ id: record.id }, reload);
}
/** 复制为一条新模板(新编码 + 名称后缀,内容与原模板一致) */
async function handleCopy(record: Recordable) {
const id = String(record?.id || '').trim();
if (!id) {
createMessage.warning('无法复制:缺少模板主键');
return;
}
try {
const full = (await queryById(id)) as Record<string, any>;
const baseCode = String(full?.templateCode ?? record?.templateCode ?? 'TPL').trim() || 'TPL';
const ts = Date.now();
let templateCode = `${baseCode}_CP_${ts}`;
if (templateCode.length > 64) {
templateCode = `CP_${ts}`;
}
const baseName = String(full?.templateName ?? record?.templateName ?? baseCode).trim() || '模板';
const templateName = `${baseName}_副本`;
await add({
templateCode,
templateName,
category: String(full?.category ?? record?.category ?? 'form'),
paperWidthMm: full?.paperWidthMm ?? record?.paperWidthMm,
paperHeightMm: full?.paperHeightMm ?? record?.paperHeightMm,
paperOrientation: String(full?.paperOrientation ?? record?.paperOrientation ?? 'portrait'),
templateJson: String(full?.templateJson ?? record?.templateJson ?? '{}'),
remark: full?.remark != null ? String(full.remark) : record?.remark != null ? String(record.remark) : '',
});
createMessage.success('已复制为新模板');
await reload();
} catch (e: any) {
createMessage.error(e?.message || '复制失败');
}
}
async function batchHandleDelete() {
await batchDelete({ ids: selectedRowKeys.value.join(',') }, reload);
}
async function handleSuccess(payload?: Recordable) {
reload();
if (payload?.isNative !== true || payload?.isUpdate === true) {
return;
}
const savedId =
payload?.savedResult?.id ||
payload?.savedResult?.result?.id ||
payload?.values?.id;
if (savedId) {
router.push({ path: '/print/native-designer', query: { id: String(savedId) } });
return;
}
const templateCode = String(payload?.values?.templateCode || '').trim();
if (!templateCode) {
return;
}
try {
const record = (await queryByCode(templateCode)) as Record<string, any>;
if (record?.id) {
router.push({ path: '/print/native-designer', query: { id: String(record.id) } });
}
} catch (_error) {
// ignore
}
}
function getTableAction(record: Recordable) {
const isNative = isNativeTemplate(record);
const actions: any[] = [
{ label: isNative ? '原生设计' : 'Hiprint设计', onClick: handleDesign.bind(null, record), auth: 'print:template:edit' },
];
if (isNative) {
actions.push({ label: '预览', onClick: handleNativeListPreview.bind(null, record), auth: 'print:template:list' });
}
actions.push(
{ label: '编辑', onClick: handleEdit.bind(null, record), auth: 'print:template:edit' },
{ label: '复制', onClick: handleCopy.bind(null, record), auth: 'print:template:add' },
{
label: '删除',
color: 'error',
popConfirm: {
title: '确认删除?',
confirm: handleDelete.bind(null, record),
},
auth: 'print:template:delete',
},
);
return actions;
}
watch(
selectedPrinterName,
(value) => {
if (!value) {
localStorage.removeItem(PRINTER_STORAGE_KEY);
return;
}
localStorage.setItem(PRINTER_STORAGE_KEY, value);
},
{ immediate: false },
);
onMounted(async () => {
try {
await initHiprintForQuickPrint();
} catch (_error) {
// ignore
}
selectedPrinterName.value = localStorage.getItem(PRINTER_STORAGE_KEY) || '__system_default__';
await refreshPrinterOptions(false);
if (!selectedPrinterName.value) {
selectedPrinterName.value = '__system_default__';
}
});
</script>
<style scoped lang="less">
.skill-field-label {
margin-bottom: 6px;
font-size: 13px;
color: rgba(0, 0, 0, 0.65);
}
.skill-json-error {
margin-top: 6px;
color: #cf1322;
font-size: 12px;
}
.skill-preview-wrap {
min-height: 360px;
max-height: 520px;
overflow: auto;
border: 1px solid #f0f0f0;
border-radius: 4px;
background: #fafafa;
}
.skill-preview-iframe {
width: 100%;
min-height: 360px;
height: 480px;
border: none;
background: #fff;
}
</style>