1486 lines
54 KiB
Vue
1486 lines
54 KiB
Vue
<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="printerSelectPlaceholder"
|
||
/>
|
||
<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-button @click="downloadPrintPlugin">下载打印插件</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="quickNativePrintOpen"
|
||
title="快速打印"
|
||
width="480px"
|
||
:footer="null"
|
||
destroy-on-close
|
||
>
|
||
<a-space direction="vertical" style="width: 100%" size="middle">
|
||
<a-alert
|
||
type="info"
|
||
show-icon
|
||
message="请选择原生模板"
|
||
description="将打开与列表「预览」相同的弹窗,在其中编辑模板 JSON、参数 JSON,并使用「浏览器打印」或 PrintDot「桥接器打印」。"
|
||
/>
|
||
<div>
|
||
<div class="skill-field-label">模板编号</div>
|
||
<a-select
|
||
v-model:value="quickNativeTemplateCode"
|
||
:options="templateCodeOptions"
|
||
style="width: 100%"
|
||
show-search
|
||
option-filter-prop="label"
|
||
placeholder="选择模板编号"
|
||
/>
|
||
</div>
|
||
<a-space style="width: 100%; justify-content: flex-end">
|
||
<a-button @click="quickNativePrintOpen = false">取消</a-button>
|
||
<a-button type="primary" :loading="quickNativePrintLoading" @click="confirmQuickOpenNativePreview">打开预览</a-button>
|
||
</a-space>
|
||
</a-space>
|
||
</a-modal>
|
||
|
||
<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,
|
||
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 { buildPdfBase64FromHtmlFragment } from './utils/printHtmlToPdfBase64';
|
||
import {
|
||
fetchPrintDotPrinters,
|
||
getPrintDotBridgeConfig,
|
||
setPrintDotBridgeConfig,
|
||
} from './utils/printDotBridge';
|
||
import { PRINT_TEMPLATE_SELECTED_PRINTER_KEY } from './utils/printNativeViaPrintDot';
|
||
|
||
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 quickNativePrintOpen = ref(false);
|
||
const quickNativeTemplateCode = ref<string>('');
|
||
const quickNativePrintLoading = ref(false);
|
||
const templateCodeOptions = ref<Array<{ label: string; value: string }>>([]);
|
||
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);
|
||
// 桥接开启时 WS/密钥变更后重新拉取桥接器打印机列表
|
||
if (printDotEnabled.value) {
|
||
void refreshPrinterOptions(false);
|
||
}
|
||
}
|
||
|
||
const printerSelectPlaceholder = computed(() =>
|
||
printDotEnabled.value ? '选择打印机(PrintDot 桥接)' : '选择打印机(本地/网络)',
|
||
);
|
||
|
||
function onPrintDotEnabledChange() {
|
||
localStorage.setItem(LS_PRINT_DOT_ENABLED, printDotEnabled.value ? '1' : '0');
|
||
void refreshPrinterOptions(false);
|
||
}
|
||
|
||
/** 下载 PrintDot 桥接器(静态资源 public/print-plugin/XSL-PrintDot.exe) */
|
||
function downloadPrintPlugin() {
|
||
const base = import.meta.env.BASE_URL || '/';
|
||
const normalizedBase = base.endsWith('/') ? base : `${base}/`;
|
||
const url = `${normalizedBase}print-plugin/XSL-PrintDot.exe`;
|
||
const link = document.createElement('a');
|
||
link.href = url;
|
||
link.setAttribute('download', 'XSL-PrintDot.exe');
|
||
link.rel = 'noopener';
|
||
document.body.appendChild(link);
|
||
link.click();
|
||
document.body.removeChild(link);
|
||
}
|
||
/** 技能转换打印:示例数据(与快速打印占位说明一致) */
|
||
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;
|
||
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 optionMap = new Map<string, { label: string; value: string }>();
|
||
// 保留「系统默认」:本地模式交给浏览器/队列;桥接模式由 resolvePrintDotPrinterName 映射为桥接器默认打印机
|
||
optionMap.set('__system_default__', { label: '系统默认打印机', value: '__system_default__' });
|
||
|
||
if (printDotEnabled.value) {
|
||
try {
|
||
const dotList = await fetchPrintDotPrinters();
|
||
dotList.forEach((p) => {
|
||
const name = String(p.name || '').trim();
|
||
if (!name) {
|
||
return;
|
||
}
|
||
const defMark = p.isDefault ? '(默认)' : '';
|
||
optionMap.set(name, { label: `${name}${defMark}`, value: name });
|
||
});
|
||
if (selectedPrinterName.value && !optionMap.has(selectedPrinterName.value)) {
|
||
optionMap.set(selectedPrinterName.value, {
|
||
label: `${selectedPrinterName.value}(手动)`,
|
||
value: selectedPrinterName.value,
|
||
});
|
||
}
|
||
printerOptions.value = Array.from(optionMap.values());
|
||
if (showMessage) {
|
||
if (dotList.length) {
|
||
createMessage.success(`已从 PrintDot 桥接识别 ${dotList.length} 台打印机`);
|
||
} else {
|
||
createMessage.warning('PrintDot 已连接但未返回打印机列表');
|
||
}
|
||
}
|
||
} catch (e: any) {
|
||
if (selectedPrinterName.value && !optionMap.has(selectedPrinterName.value)) {
|
||
optionMap.set(selectedPrinterName.value, {
|
||
label: `${selectedPrinterName.value}(手动)`,
|
||
value: selectedPrinterName.value,
|
||
});
|
||
}
|
||
printerOptions.value = Array.from(optionMap.values());
|
||
if (showMessage) {
|
||
createMessage.warning(`PrintDot:${e?.message || '无法连接本地桥接器'}`);
|
||
}
|
||
}
|
||
return;
|
||
}
|
||
|
||
const payload = (await queryPrinters()) as Record<string, any>;
|
||
const names = [
|
||
...(Array.isArray(payload?.serverPrinters) ? payload.serverPrinters : []),
|
||
...(Array.isArray(payload?.networkPrinters) ? payload.networkPrinters : []),
|
||
]
|
||
.map((item) => String(item || '').trim())
|
||
.filter(Boolean)
|
||
.filter((item, index, arr) => arr.indexOf(item) === index);
|
||
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() {
|
||
quickNativeTemplateCode.value = '';
|
||
if (!templateCodeOptions.value.length) {
|
||
await loadTemplateCodeOptions();
|
||
}
|
||
quickNativePrintOpen.value = true;
|
||
}
|
||
|
||
/** 快速打印:仅支持原生模板,打开与列表相同的预览弹窗(浏览器打印 / PrintDot) */
|
||
async function confirmQuickOpenNativePreview() {
|
||
const templateCode = String(quickNativeTemplateCode.value || '').trim();
|
||
if (!templateCode) {
|
||
createMessage.warning('请先选择模板编号');
|
||
return;
|
||
}
|
||
quickNativePrintLoading.value = true;
|
||
try {
|
||
const tpl = (await queryByCode(templateCode)) as Record<string, any>;
|
||
const row = (tpl?.result ?? tpl) as Record<string, any>;
|
||
const id = String(row?.id ?? tpl?.id ?? '').trim();
|
||
if (!id) {
|
||
createMessage.error('未找到该模板记录,请确认编号是否正确');
|
||
return;
|
||
}
|
||
const templateJsonText = String(row?.templateJson ?? tpl?.templateJson ?? '').trim();
|
||
let engine: string | undefined;
|
||
if (templateJsonText) {
|
||
try {
|
||
engine = JSON.parse(templateJsonText)?.engine;
|
||
} catch {
|
||
createMessage.error('模板 JSON 无法解析,无法判断是否为原生模板');
|
||
return;
|
||
}
|
||
}
|
||
if (engine !== 'native') {
|
||
createMessage.warning('快速打印仅支持「原生模板」(engine 为 native),请从列表预览 hiprint 模板或使用设计器');
|
||
return;
|
||
}
|
||
nativeListPreviewTemplateId.value = id;
|
||
nativeListPreviewOpen.value = true;
|
||
quickNativePrintOpen.value = false;
|
||
} catch (error: any) {
|
||
createMessage.error(`加载模板失败:${error?.message || '未知错误'}`);
|
||
} finally {
|
||
quickNativePrintLoading.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>`;
|
||
}
|
||
|
||
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(PRINT_TEMPLATE_SELECTED_PRINTER_KEY);
|
||
return;
|
||
}
|
||
localStorage.setItem(PRINT_TEMPLATE_SELECTED_PRINTER_KEY, value);
|
||
},
|
||
{ immediate: false },
|
||
);
|
||
|
||
onMounted(async () => {
|
||
try {
|
||
await initHiprintForQuickPrint();
|
||
} catch (_error) {
|
||
// ignore
|
||
}
|
||
selectedPrinterName.value = localStorage.getItem(PRINT_TEMPLATE_SELECTED_PRINTER_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>
|