新增打印模板绑定功能,支持业务与打印模板的映射配置。实现打印模板的增删改查操作,优化打印数据的生成逻辑,提升打印模板的灵活性和用户体验。同时,新增打印机查询接口,增强打印服务的可用性和实时性。

This commit is contained in:
geht
2026-05-13 15:49:51 +08:00
parent 210f3614ea
commit c3f8190537
32 changed files with 2323 additions and 229 deletions

View File

@@ -0,0 +1,29 @@
import { defHttp } from '/@/utils/http/axios';
enum Api {
list = '/print/bizTemplateBind/list',
add = '/print/bizTemplateBind/add',
edit = '/print/bizTemplateBind/edit',
deleteOne = '/print/bizTemplateBind/delete',
bizTypes = '/print/bizTemplateBind/bizTypes',
parseTemplateFields = '/print/bizTemplateBind/parseTemplateFields',
previewMappedData = '/print/bizTemplateBind/previewMappedData',
}
export const list = (params) => defHttp.get({ url: Api.list, params });
// 与系统其它模块一致body 走 params 键
export const add = (params) => defHttp.post({ url: Api.add, params });
export const edit = (params) => defHttp.put({ url: Api.edit, params });
export const deleteOne = (params, handleSuccess?) =>
defHttp.delete({ url: Api.deleteOne, params }, { joinParamsToUrl: true }).then(() => handleSuccess?.());
export const bizTypes = () => defHttp.get({ url: Api.bizTypes });
export const parseTemplateFields = (templateId: string) =>
defHttp.get({
url: Api.parseTemplateFields,
params: { templateId, _t: Date.now() },
});
/** 预览映射后的打印数据 */
export const previewMappedData = (data: { bizCode: string; bizDataJson: Record<string, unknown> }) =>
defHttp.post({ url: Api.previewMappedData, data });

View File

@@ -0,0 +1,8 @@
import type { BasicColumn } from '/@/components/Table';
export const columns: BasicColumn[] = [
{ title: '业务编码', dataIndex: 'bizCode', width: 200 },
{ title: '业务名称', dataIndex: 'bizName', width: 140 },
{ title: '模板编码', dataIndex: 'templateCode', width: 180 },
{ title: '备注', dataIndex: 'remark', ellipsis: true },
];

View File

@@ -0,0 +1,414 @@
<template>
<div>
<BasicTable @register="registerTable">
<template #tableTitle>
<a-button type="primary" @click="openCreate" v-auth="'print:bizBind:add'">新增绑定</a-button>
</template>
<template #action="{ record }">
<TableAction
:actions="[
{
label: '编辑',
onClick: () => openEdit(record),
auth: 'print:bizBind:edit',
},
{
label: '删除',
color: 'error',
popConfirm: {
title: '确认删除该绑定?',
confirm: () => handleDelete(record),
},
auth: 'print:bizBind:delete',
},
]"
/>
</template>
</BasicTable>
<BasicModal
@register="registerModal"
:title="modalTitle"
width="920px"
@ok="submitModal"
:confirm-loading="modalSubmitLoading"
destroy-on-close
>
<a-spin :spinning="tplLoading || parseLoading">
<a-space direction="vertical" style="width: 100%" size="middle">
<a-alert
type="info"
show-icon
message="配置步骤"
description="1选择业务类型2选择已发布的打印模板3为模板每个占位字段bindField指定对应的业务 JSON 字段;可点击「同名匹配」快速对齐。"
/>
<a-form layout="vertical">
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="业务" required>
<a-select
v-model:value="form.bizCode"
:options="bizSelectOptions"
placeholder="选择业务"
show-search
option-filter-prop="label"
:disabled="isEditMode"
@change="onBizCodeChange"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="打印模板" required>
<a-select
v-model:value="form.templateId"
:options="tplSelectOptions"
placeholder="选择模板"
show-search
option-filter-prop="label"
@change="onTemplateChange"
/>
</a-form-item>
</a-col>
</a-row>
<a-form-item label="备注">
<a-input v-model:value="form.remark" placeholder="可选" />
</a-form-item>
</a-form>
<a-space wrap>
<a-button type="primary" ghost @click="reloadTemplateFields" :loading="parseLoading">
解析模板占位字段
</a-button>
<a-button @click="autoMatchFields" :disabled="!bizFields.length || !tplFields.length">
同名自动匹配
</a-button>
</a-space>
<div v-if="tplFields.length">
<div style="margin-bottom: 8px; font-weight: 500">字段映射</div>
<a-table
size="small"
row-key="templateField"
:pagination="false"
:columns="mapTableColumns"
:data-source="mappingRows"
bordered
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'bizField'">
<a-select
v-model:value="record.bizField"
:options="bizFieldOptions"
allow-clear
show-search
option-filter-prop="label"
style="width: 100%"
placeholder="选择业务字段"
/>
</template>
</template>
</a-table>
</div>
<a-empty v-else-if="form.templateId && !parseLoading" description="请点击「解析模板占位字段」或切换模板" />
<a-divider />
<div style="font-weight: 500">映射预览可选</div>
<a-textarea
v-model:value="previewBizJson"
placeholder='粘贴业务 JSON例如{"barcode":"TEST001","materialName":"胶料A"}'
:rows="4"
/>
<a-button type="dashed" @click="runPreview" :loading="previewLoading">生成打印数据预览</a-button>
<pre v-if="previewResult" class="preview-pre">{{ previewResult }}</pre>
</a-space>
</a-spin>
</BasicModal>
</div>
</template>
<script lang="ts" setup>
import { computed, ref, unref } from 'vue';
import { BasicTable, TableAction, useTable } from '/@/components/Table';
import { BasicModal, useModal } from '/@/components/Modal';
import { useMessage } from '/@/hooks/web/useMessage';
import { columns } from './bizTemplateBind.data';
import * as Api from './bizTemplateBind.api';
import { list as tplList } from '../template/printTemplate.api';
const { createMessage } = useMessage();
interface BizTypeItem {
bizCode: string;
bizName: string;
fields: { fieldKey: string; label: string; description?: string }[];
}
interface TplFieldItem {
bindField: string;
elementType?: string;
titleHint?: string;
}
interface MappingRow {
templateField: string;
bizField?: string;
elementType?: string;
titleHint?: string;
}
const bizTypesRef = ref<BizTypeItem[]>([]);
const tplListRef = ref<{ id: string; templateCode: string; templateName: string }[]>([]);
const tplLoading = ref(false);
const parseLoading = ref(false);
const modalSubmitLoading = ref(false);
const previewLoading = ref(false);
const previewBizJson = ref('');
const previewResult = ref('');
const form = ref({
id: '' as string | undefined,
bizCode: undefined as string | undefined,
bizName: '' as string | undefined,
templateId: undefined as string | undefined,
remark: '' as string | undefined,
});
const tplFields = ref<TplFieldItem[]>([]);
const bizFields = ref<BizTypeItem['fields']>([]);
const mappingRows = ref<MappingRow[]>([]);
const isEditMode = ref(false);
const modalTitle = computed(() => (unref(isEditMode) ? '编辑业务打印绑定' : '新增业务打印绑定'));
const bizSelectOptions = computed(() =>
unref(bizTypesRef).map((b) => ({
label: `${b.bizName}${b.bizCode}`,
value: b.bizCode,
})),
);
const tplSelectOptions = computed(() =>
unref(tplListRef).map((t) => ({
label: `${t.templateName}${t.templateCode}`,
value: t.id,
})),
);
const bizFieldOptions = computed(() =>
unref(bizFields).map((f) => ({
label: f.label ? `${f.label}${f.fieldKey}` : f.fieldKey,
value: f.fieldKey,
})),
);
const mapTableColumns = [
{ title: '模板占位bindField', dataIndex: 'templateField', width: 220 },
{ title: '类型', dataIndex: 'elementType', width: 100 },
{ title: '标题/提示', dataIndex: 'titleHint', ellipsis: true },
{ title: '业务字段', key: 'bizField', width: 260 },
];
const [registerTable, { reload }] = useTable({
title: '业务打印绑定',
api: Api.list,
columns,
useSearchForm: false,
showTableSetting: true,
bordered: true,
showIndexColumn: true,
// 必须与模板插槽 #action 对应否则操作列不会渲染useTable 无 useListPage 的默认 slots
actionColumn: {
width: 160,
title: '操作',
fixed: 'right',
dataIndex: 'action',
slots: { customRender: 'action' },
},
});
const [registerModal, { openModal, closeModal }] = useModal();
async function loadBizTypes() {
const res = await Api.bizTypes();
bizTypesRef.value = res || [];
}
async function loadAllTemplates() {
tplLoading.value = true;
try {
const res = await tplList({ pageNo: 1, pageSize: 500 });
tplListRef.value = res?.records ?? [];
} finally {
tplLoading.value = false;
}
}
function onBizCodeChange(code: string) {
const hit = unref(bizTypesRef).find((b) => b.bizCode === code);
bizFields.value = hit?.fields ?? [];
form.value.bizName = hit?.bizName;
}
async function onTemplateChange() {
tplFields.value = [];
mappingRows.value = [];
await reloadTemplateFields();
}
async function reloadTemplateFields() {
const tid = form.value.templateId;
if (!tid) {
tplFields.value = [];
mappingRows.value = [];
return;
}
parseLoading.value = true;
try {
const list = (await Api.parseTemplateFields(tid)) as TplFieldItem[];
tplFields.value = list || [];
rebuildMappingRows();
} finally {
parseLoading.value = false;
}
}
function rebuildMappingRows() {
const saved = unref(savedMappingRef);
mappingRows.value = unref(tplFields).map((t) => {
const templateField = t.bindField;
const hit = saved.find((x) => x.templateField === templateField);
return {
templateField,
bizField: hit?.bizField,
elementType: t.elementType,
titleHint: t.titleHint,
};
});
}
const savedMappingRef = ref<{ templateField: string; bizField?: string }[]>([]);
function autoMatchFields() {
const set = new Map(unref(bizFields).map((f) => [f.fieldKey, f.fieldKey]));
for (const row of unref(mappingRows)) {
if (set.has(row.templateField)) {
row.bizField = row.templateField;
}
}
mappingRows.value = [...unref(mappingRows)];
}
function buildFieldMappingJson() {
const arr = unref(mappingRows)
.filter((r) => r.bizField)
.map((r) => ({ templateField: r.templateField, bizField: r.bizField }));
return JSON.stringify(arr);
}
async function openCreate() {
isEditMode.value = false;
savedMappingRef.value = [];
form.value = { id: undefined, bizCode: undefined, bizName: undefined, templateId: undefined, remark: undefined };
tplFields.value = [];
bizFields.value = [];
mappingRows.value = [];
previewBizJson.value = '';
previewResult.value = '';
await loadBizTypes();
await loadAllTemplates();
openModal(true);
}
async function openEdit(record: Recordable) {
isEditMode.value = true;
await loadBizTypes();
await loadAllTemplates();
form.value = {
id: record.id,
bizCode: record.bizCode,
bizName: record.bizName,
templateId: record.templateId,
remark: record.remark,
};
onBizCodeChange(record.bizCode);
try {
savedMappingRef.value = JSON.parse(record.fieldMappingJson || '[]');
} catch {
savedMappingRef.value = [];
}
previewBizJson.value = '';
previewResult.value = '';
openModal(true);
await reloadTemplateFields();
}
async function submitModal() {
if (!form.value.bizCode) {
createMessage.warning('请选择业务');
return Promise.reject();
}
if (!form.value.templateId) {
createMessage.warning('请选择打印模板');
return Promise.reject();
}
modalSubmitLoading.value = true;
try {
const payload = {
id: form.value.id,
bizCode: form.value.bizCode,
bizName: form.value.bizName,
templateId: form.value.templateId,
remark: form.value.remark,
fieldMappingJson: buildFieldMappingJson(),
};
if (unref(isEditMode)) {
await Api.edit(payload);
} else {
await Api.add(payload);
}
closeModal();
reload();
} finally {
modalSubmitLoading.value = false;
}
}
async function handleDelete(record: Recordable) {
await Api.deleteOne({ id: record.id }, () => reload());
}
async function runPreview() {
if (!form.value.bizCode) {
createMessage.warning('请先选择业务;预览读取的是服务器已保存的绑定配置');
return;
}
let obj: Record<string, unknown> = {};
try {
obj = previewBizJson.value ? (JSON.parse(previewBizJson.value) as Record<string, unknown>) : {};
} catch {
previewResult.value = '业务 JSON 格式不正确';
return;
}
previewLoading.value = true;
try {
const res = await Api.previewMappedData({ bizCode: form.value.bizCode, bizDataJson: obj });
previewResult.value = JSON.stringify(res, null, 2);
} catch (e: unknown) {
previewResult.value = e instanceof Error ? e.message : String(e);
} finally {
previewLoading.value = false;
}
}
</script>
<style scoped>
.preview-pre {
background: #f5f5f5;
padding: 12px;
border-radius: 4px;
max-height: 240px;
overflow: auto;
font-size: 12px;
}
</style>

View File

@@ -92,6 +92,20 @@ function enhancePrintDotErrorMessage(raw: string): string {
if (/SumatraPDF\.exe not found/i.test(m) || /SUMATRAPDF_PATH/i.test(m)) {
return `${m}。本地处理PrintDot 依赖 SumatraPDF 静默打印 PDF。请安装 Sumatra PDF 后任选其一:将 SumatraPDF.exe 放在 PrintDot 客户端 exe 同目录;或将 Sumatra 安装目录加入系统 PATH或设置用户/系统环境变量 SUMATRAPDF_PATH 指向 SumatraPDF.exe 的完整路径,然后重启 PrintDot。`;
}
/** 桥接端在等待 Windows 打印队列接受作业(默认约 2 分钟)未果 */
if (/not queued/i.test(m) || /Printed\s+0\s*\/\s*\d+\s+copies/i.test(m)) {
return `${m}
【说明】PrintDot 已通过 SumatraPDF 发起静默打印,但在约定时间内未检测到作业进入系统打印队列。
【建议逐项排查】
1. 打印机是否开机、联网(网络打印机)、线缆/USB 是否正常。
2. Windows「设备和打印机」中该打印机是否就绪、无暂停打印队列里是否有卡住的任务可先清空队列
3. 下拉选择的打印机名称是否与系统完全一致(可在本页「刷新打印机」后重选)。
4. 重启「Print Spooler」打印后台服务或重启 PrintDot 客户端后再试。
5. 模板版面过大时生成的 PDF 体积大,可能导致 Sumatra 处理变慢——可先简化模板或缩小画布后再试。
6. 若频繁超时,需在 PrintDot 桌面端放宽「队列确认」超时(该 2 分钟由客户端决定,浏览器无法修改)。`;
}
return m;
}

View File

@@ -56,6 +56,11 @@ export type BuildPdfFromHtmlOptions = {
* false整张版面压成一页 PDF长图一页一般仅特殊场景使用
*/
paginate?: boolean;
/**
* 是否严格使用入参纸张尺寸(默认 false 保持历史行为)。
* 原生模板桥接打印建议开启,避免内容测量误差把小标签纸扩成 A4。
*/
exactPaperSize?: boolean;
};
/**
@@ -70,6 +75,7 @@ export async function buildPdfBase64FromHtmlFragment(
options: BuildPdfFromHtmlOptions = {},
): Promise<string> {
const paginate = options.paginate !== false;
const exactPaperSize = options.exactPaperSize === true;
const [{ jsPDF }, html2canvasModule] = await Promise.all([import('jspdf'), import('html2canvas')]);
const html2canvas = html2canvasModule.default;
const container = document.createElement('div');
@@ -152,10 +158,11 @@ export async function buildPdfBase64FromHtmlFragment(
const pad = 1;
if (paginate) {
const sheetW = Math.max(widthMm, pxToMm(sw) + pad);
const sheetW = exactPaperSize ? Math.max(1, widthMm) : Math.max(widthMm, pxToMm(sw) + pad);
const sheetH = Math.max(1, heightMm);
const sliceH = Math.max(1, Math.round(mmToPx(sheetH) * scale));
const pdf = new jsPDF({ unit: 'mm', format: [sheetW, sheetH] });
const orientation = sheetW > sheetH ? 'landscape' : 'portrait';
const pdf = new jsPDF({ unit: 'mm', orientation, format: [sheetW, sheetH] });
let y = 0;
let first = true;
/** 余量不足一页高的 2% 时视为测量噪声,避免多出一页空白 */
@@ -193,7 +200,7 @@ export async function buildPdfBase64FromHtmlFragment(
// 单页长图模式paginate: false
const contentWidthMm = pxToMm(sw);
const contentHeightMm = pxToMm(sh);
const minW = Math.max(widthMm, contentWidthMm) + pad;
const minW = exactPaperSize ? Math.max(1, widthMm) : Math.max(widthMm, contentWidthMm) + pad;
const minH = Math.max(heightMm, contentHeightMm) + pad;
const canvasRatio = cw / ch;
let pdfH = Math.max(minH, minW / canvasRatio);
@@ -202,7 +209,8 @@ export async function buildPdfBase64FromHtmlFragment(
pdfW = minW;
pdfH = pdfW / canvasRatio;
}
const pdf = new jsPDF({ unit: 'mm', format: [pdfW, pdfH] });
const orientation = pdfW > pdfH ? 'landscape' : 'portrait';
const pdf = new jsPDF({ unit: 'mm', orientation, format: [pdfW, pdfH] });
const imgData = canvas.toDataURL('image/jpeg', 0.92);
pdf.addImage(imgData, 'JPEG', 0, 0, pdfW, pdfH);
return arrayBufferToBase64(pdf.output('arraybuffer'));

View File

@@ -20,13 +20,14 @@ export async function printNativeSchemaViaPrintDot(params: {
const inner = extractBodyInnerHtmlFromFullDocument(fullHtml);
const pdfBase64 = await buildPdfBase64FromHtmlFragment(inner, params.schema.page.width, params.schema.page.height, {
paginate: true,
exactPaperSize: true,
});
const printers = await fetchPrintDotPrinters();
const fromStore =
params.printerSelection ?? localStorage.getItem(PRINT_TEMPLATE_SELECTED_PRINTER_KEY) ?? '__system_default__';
const resolved = resolvePrintDotPrinterName(fromStore, printers);
if (!resolved) {
throw new Error('未解析到可用打印机:请在模板列表选择打印机,或启动 PrintDot 后刷新打印机列表');
throw new Error('未解析到可用打印机:请在本页或打印模板页选择打印机,并确保本机 PrintDot 已启动后刷新打印机列表');
}
const result = await printDotSendPdf({
printer: resolved,