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

1607 lines
60 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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-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="serverText">服务端直打纯文本</a-radio-button>
</a-radio-group>
<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';
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' | 'serverText'>('templateStyle');
/** 技能转换打印:示例数据(与快速打印占位说明一致) */
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 (showMessage) {
if (names.length) {
createMessage.success(`已从服务端识别到 ${names.length} 台打印机`);
} else {
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 buildPdfBase64FromTemplate(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>`;
}
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) {
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 {
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>