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

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

@@ -12,6 +12,10 @@ enum Api {
importExcel = '/xslmes/mesXslRawMaterialCard/importExcel',
exportXls = '/xslmes/mesXslRawMaterialCard/exportXls',
updatePriority = '/xslmes/mesXslRawMaterialCard/updatePriority',
/** 与打印模板页 queryPrinters 返回结构一致 */
queryPrinters = '/xslmes/mesXslRawMaterialCard/queryPrinters',
prepareNativePrint = '/xslmes/mesXslRawMaterialCard/prepareNativePrint',
printPdf = '/xslmes/mesXslRawMaterialCard/printPdf',
}
export const getExportUrl = Api.exportXls;
@@ -47,3 +51,15 @@ export const saveOrUpdate = (params, isUpdate) => {
export const updatePriority = (id: string, priorityPickup: string) =>
defHttp.put({ url: Api.updatePriority, params: { id, priorityPickup } }, { joinParamsToUrl: true });
export const queryPrinters = () => defHttp.get({ url: Api.queryPrinters });
export const prepareNativePrint = (id: string) =>
defHttp.get({
url: Api.prepareNativePrint,
params: { id, _t: Date.now() },
});
/** id + 前端生成的 pdfBase64printerName 空则用默认队列 */
export const printPdf = (data: { id: string; printerName?: string; pdfBase64: string; fileName?: string }) =>
defHttp.post({ url: Api.printPdf, data, timeout: 3 * 60 * 1000 });

View File

@@ -7,6 +7,51 @@
<a-button type="primary" v-auth="'xslmes:mes_xsl_raw_material_card:add'" @click="handleAdd" preIcon="ant-design:plus-outlined"> 新增</a-button>
<a-button type="primary" v-auth="'xslmes:mes_xsl_raw_material_card:exportXls'" preIcon="ant-design:export-outlined" @click="onExportXls"> 导出</a-button>
<j-upload-button type="primary" v-auth="'xslmes:mes_xsl_raw_material_card:importExcel'" preIcon="ant-design:import-outlined" @click="onImportXls">导入</j-upload-button>
<a-checkbox v-model:checked="printDotEnabled" style="margin-left: 8px" @change="onPrintDotEnabledChange">
PrintDot 桥接
</a-checkbox>
<a-input
v-model:value="printDotWsUrl"
style="width: 220px; margin-left: 8px"
placeholder="ws://127.0.0.1:1122/ws"
@blur="persistPrintDotConfig"
/>
<a-input-password
v-model:value="printDotKey"
style="width: 130px; margin-left: 8px"
placeholder="密钥(可选)"
autocomplete="new-password"
@blur="persistPrintDotConfig"
/>
<a-button @click="downloadPrintPlugin">下载打印插件</a-button>
<a-select
v-model:value="selectedPrinterName"
:options="printerOptions"
style="width: 220px; margin-left: 8px"
allow-clear
show-search
option-filter-prop="label"
:placeholder="printerSelectPlaceholder"
/>
<a-button @click="() => refreshPrinterOptions(true)">刷新打印机</a-button>
<a-input
v-model:value="manualPrinterName"
style="width: 150px; margin-left: 8px"
placeholder="手动输入打印机名"
@press-enter="addManualPrinter"
/>
<a-button @click="addManualPrinter">添加</a-button>
<a-button
type="primary"
ghost
v-auth="'xslmes:mes_xsl_raw_material_card:edit'"
:loading="printLoading"
:disabled="selectedRowKeys.length === 0 || !printDotEnabled"
@click="handlePrintSelected"
>
<Icon icon="ant-design:printer-outlined" />
打印选中
</a-button>
<a-dropdown v-if="selectedRowKeys.length > 0">
<template #overlay>
<a-menu>
@@ -42,20 +87,73 @@
</BasicTable>
<!-- 表单区域 -->
<MesXslRawMaterialCardModal @register="registerModal" @success="handleSuccess" />
<RawMaterialCardPrintPreviewModal
v-model:open="printPreviewOpen"
:card-id="printPreviewCardId"
:barcode="printPreviewBarcode"
/>
</div>
</template>
<script lang="ts" name="xslmes-mesXslRawMaterialCard" setup>
import { ref, reactive } from 'vue';
import { onMounted, ref, reactive, watch } from 'vue';
import { Icon } from '/@/components/Icon';
import { BasicTable, useTable, TableAction } from '/@/components/Table';
import { useModal } from '/@/components/Modal';
import { useListPage } from '/@/hooks/system/useListPage';
import MesXslRawMaterialCardModal from './components/MesXslRawMaterialCardModal.vue';
import RawMaterialCardPrintPreviewModal from './components/RawMaterialCardPrintPreviewModal.vue';
import { columns, searchFormSchema, superQuerySchema } from './MesXslRawMaterialCard.data';
import { list, deleteOne, batchDelete, getImportUrl, getExportUrl, updatePriority } from './MesXslRawMaterialCard.api';
import {
list,
deleteOne,
batchDelete,
getImportUrl,
getExportUrl,
updatePriority,
prepareNativePrint,
} from './MesXslRawMaterialCard.api';
import { useMessage } from '/@/hooks/web/useMessage';
import {
PRINT_TEMPLATE_SELECTED_PRINTER_KEY,
printNativeSchemaViaPrintDot,
} from '/@/views/print/template/utils/printNativeViaPrintDot';
import { normalizeImportedNativeSchema } from '/@/views/print/template/native/core/nativeSchemaNormalize';
import {
fetchPrintDotPrinters,
getPrintDotBridgeConfig,
setPrintDotBridgeConfig,
} from '/@/views/print/template/utils/printDotBridge';
const { createMessage } = useMessage();
const LS_PRINT_DOT_ENABLED = 'qhmes_print_dot_enabled';
const printDotEnabled = ref(localStorage.getItem(LS_PRINT_DOT_ENABLED) !== '0');
const printDotCfg = getPrintDotBridgeConfig();
const printDotWsUrl = ref(printDotCfg.wsUrl);
const printDotKey = ref(printDotCfg.key);
function persistPrintDotConfig() {
setPrintDotBridgeConfig(printDotWsUrl.value, printDotKey.value);
void refreshPrinterOptions(false);
}
function onPrintDotEnabledChange() {
localStorage.setItem(LS_PRINT_DOT_ENABLED, printDotEnabled.value ? '1' : '0');
}
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 queryParam = reactive<any>({});
const checkedKeys = ref<Array<string | number>>([]);
const [registerModal, { openModal }] = useModal();
@@ -74,8 +172,11 @@
],
},
actionColumn: {
width: 120,
width: 248,
fixed: 'right',
title: '操作',
dataIndex: 'action',
slots: { customRender: 'action' },
},
beforeFetch: (params) => {
return Object.assign(params, queryParam);
@@ -92,9 +193,207 @@
},
});
const [registerTable, { reload }, { rowSelection, selectedRowKeys }] = tableContext;
const [registerTable, { reload }, { rowSelection, selectedRowKeys, selectedRows }] = tableContext;
const superQueryConfig = reactive(superQuerySchema);
/** 打印预览弹窗 */
const printPreviewOpen = ref(false);
const printPreviewCardId = ref<string | null>(null);
const printPreviewBarcode = ref<string | undefined>(undefined);
function handlePrintPreview(record: Recordable) {
printPreviewCardId.value = record.id as string;
printPreviewBarcode.value = record.barcode as string | undefined;
printPreviewOpen.value = true;
}
/** 与打印模板列表共用 localStorage 键,打印机选择保持一致 */
const printerOptions = ref<Array<{ label: string; value: string }>>([]);
const selectedPrinterName = ref<string>('__system_default__');
const manualPrinterName = ref('');
const printLoading = ref(false);
/** 与打印模板列表启用 PrintDot 时一致:仅本机桥接打印机 */
const printerSelectPlaceholder = '选择打印机PrintDot 桥接)';
watch(selectedPrinterName, (v) => {
if (v) {
localStorage.setItem(PRINT_TEMPLATE_SELECTED_PRINTER_KEY, v);
}
});
/** 与打印模板列表「PrintDot 桥接」勾选时相同:仅从本机 WebSocket 获取打印机 */
async function refreshPrinterOptions(showMessage = true) {
const optionMap = new Map<string, { label: string; value: string }>();
optionMap.set('__system_default__', { label: '系统默认打印机', value: '__system_default__' });
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: unknown) {
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 instanceof Error ? e.message : String(e)}`);
}
}
}
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 executePrint(record: Recordable, options?: { silentSuccess?: boolean }) {
try {
const prep = (await prepareNativePrint(record.id as string)) as Record<string, unknown>;
const templateJsonRaw = prep.templateJson as string;
const printData = prep.printData as Record<string, unknown>;
const paperWidthMm = Number((prep as any).paperWidthMm ?? 0);
const paperHeightMm = Number((prep as any).paperHeightMm ?? 0);
const paperOrientation = String((prep as any).paperOrientation || '').toLowerCase();
if (!templateJsonRaw) {
throw new Error('模板 JSON 为空');
}
let raw: unknown;
try {
raw = typeof templateJsonRaw === 'string' ? JSON.parse(templateJsonRaw) : templateJsonRaw;
} catch {
throw new Error('模板 JSON 格式错误');
}
const schema = normalizeImportedNativeSchema(raw);
// 以模板主表的纸张配置为准,避免 schema 页面尺寸与模板设置不同步导致方向错误/内容缩小。
if (paperWidthMm > 0 && paperHeightMm > 0) {
const orient = paperOrientation === 'landscape' ? 'landscape' : paperOrientation === 'portrait' ? 'portrait' : '';
const normalized =
orient === 'landscape'
? {
width: Math.max(paperWidthMm, paperHeightMm),
height: Math.min(paperWidthMm, paperHeightMm),
}
: orient === 'portrait'
? {
width: Math.min(paperWidthMm, paperHeightMm),
height: Math.max(paperWidthMm, paperHeightMm),
}
: {
width: paperWidthMm,
height: paperHeightMm,
};
schema.page.width = normalized.width;
schema.page.height = normalized.height;
}
/** 与打印模板原生打印一致render → PDF → PrintDot WebSocket */
await printNativeSchemaViaPrintDot({
schema,
data: printData as Record<string, unknown>,
jobName: `原材料卡片-${(record.barcode as string) || record.id}.pdf`,
printerSelection:
selectedPrinterName.value ||
localStorage.getItem(PRINT_TEMPLATE_SELECTED_PRINTER_KEY) ||
'__system_default__',
});
if (!options?.silentSuccess) {
createMessage.success('已通过 PrintDot 提交打印');
}
} catch (e: unknown) {
throw new Error(e instanceof Error ? e.message : String(e));
}
}
function handlePrintSelected() {
if (!printDotEnabled.value) {
createMessage.warning('请先开启 PrintDot 桥接');
return;
}
const rows = selectedRows.value || [];
if (!rows.length) {
createMessage.warning('请至少勾选一条记录后再点击「打印选中」');
return;
}
printLoading.value = true;
const hideLoadingMsg = createMessage.loading(`正在打印 ${rows.length} 条记录,请稍候…`, 0);
(async () => {
let ok = 0;
let firstError = '';
for (const row of rows) {
try {
await executePrint(row, { silentSuccess: true });
ok += 1;
} catch (e: unknown) {
if (!firstError) {
firstError = e instanceof Error ? e.message : String(e);
}
}
}
if (ok === rows.length) {
createMessage.success(`已通过 PrintDot 提交 ${ok} 条打印任务`);
} else {
createMessage.warning(`打印完成:成功 ${ok},失败 ${rows.length - ok}${firstError ? `。首条错误:${firstError}` : ''}`);
}
hideLoadingMsg();
printLoading.value = false;
})();
}
function handlePrintRow(record: Recordable) {
if (!printDotEnabled.value) {
createMessage.warning('请先开启 PrintDot 桥接');
return;
}
printLoading.value = true;
const hideLoadingMsg = createMessage.loading('正在生成 PDF 并提交打印,版面复杂时可能需数十秒,请稍候…', 0);
executePrint(record)
.then(() => {
createMessage.success('已通过 PrintDot 提交打印');
})
.catch((e: unknown) => {
createMessage.error(e instanceof Error ? e.message : String(e));
})
.finally(() => {
hideLoadingMsg();
printLoading.value = false;
});
}
onMounted(() => {
const saved = localStorage.getItem(PRINT_TEMPLATE_SELECTED_PRINTER_KEY);
if (saved) {
selectedPrinterName.value = saved;
}
refreshPrinterOptions(false);
});
function handleSuperQuery(params) {
Object.keys(params).map((k) => {
queryParam[k] = params[k];
@@ -145,6 +444,16 @@
onClick: handleEdit.bind(null, record),
auth: 'xslmes:mes_xsl_raw_material_card:edit',
},
{
label: '打印预览',
onClick: handlePrintPreview.bind(null, record),
auth: 'xslmes:mes_xsl_raw_material_card:edit',
},
{
label: '打印',
onClick: handlePrintRow.bind(null, record),
auth: 'xslmes:mes_xsl_raw_material_card:edit',
},
];
}

View File

@@ -0,0 +1,211 @@
<template>
<a-modal
v-model:open="innerOpen"
:title="modalTitle"
width="960px"
:footer="null"
destroy-on-close
wrap-class-name="raw-material-card-print-preview-modal"
@cancel="onClose"
>
<a-spin :spinning="loading">
<div v-if="errorText" class="preview-error">{{ errorText }}</div>
<div v-else class="preview-body">
<iframe
v-if="previewHtml"
ref="previewIframeRef"
class="preview-iframe"
title="原材料卡片打印预览"
:srcdoc="previewHtml"
/>
<a-empty v-else-if="!loading" description="暂无预览内容" />
</div>
</a-spin>
<div class="preview-footer">
<a-space>
<a-button @click="innerOpen = false">关闭</a-button>
<a-button type="primary" :disabled="!previewHtml || !!errorText" @click="handleBrowserPrint">
<Icon icon="ant-design:printer-outlined" />
浏览器打印
</a-button>
</a-space>
</div>
</a-modal>
</template>
<script lang="ts" setup>
import { computed, ref, watch } from 'vue';
import { Icon } from '/@/components/Icon';
import { useMessage } from '/@/hooks/web/useMessage';
import { prepareNativePrint } from '../MesXslRawMaterialCard.api';
import { renderNativePrintHtml } from '/@/views/print/template/native/core/printRenderer';
import { normalizeImportedNativeSchema } from '/@/views/print/template/native/core/nativeSchemaNormalize';
const props = defineProps<{
open: boolean;
/** 卡片主键,有值时拉取模板与业务数据并渲染 */
cardId: string | null;
/** 展示在标题上的条码/说明 */
barcode?: string;
}>();
const emit = defineEmits<{
(e: 'update:open', v: boolean): void;
}>();
const { createMessage } = useMessage();
const innerOpen = computed({
get: () => props.open,
set: (v: boolean) => emit('update:open', v),
});
const modalTitle = computed(() => {
const b = String(props.barcode || '').trim();
return b ? `原材料卡片打印预览(条码:${b}` : '原材料卡片打印预览';
});
const loading = ref(false);
const errorText = ref('');
const previewHtml = ref('');
const previewIframeRef = ref<HTMLIFrameElement | null>(null);
async function loadPreview(id: string) {
loading.value = true;
errorText.value = '';
previewHtml.value = '';
try {
const prep = (await prepareNativePrint(id)) as Record<string, unknown>;
const templateJsonRaw = prep.templateJson as string;
const printData = prep.printData as Record<string, unknown>;
if (!templateJsonRaw) {
throw new Error('模板 JSON 为空,请检查「业务打印绑定」是否已配置');
}
let raw: unknown;
try {
raw = typeof templateJsonRaw === 'string' ? JSON.parse(templateJsonRaw) : templateJsonRaw;
} catch {
throw new Error('模板 JSON 格式错误');
}
const schema = normalizeImportedNativeSchema(raw);
previewHtml.value = await renderNativePrintHtml(schema, printData as Record<string, unknown>);
} catch (e: unknown) {
errorText.value = e instanceof Error ? e.message : String(e);
} finally {
loading.value = false;
}
}
/**
* 在 Modal 内对 iframe 直接 print() 时,打印对话框易被遮罩层级挡住或焦点异常,表现为「点了没反应」。
* 改为在 body 下挂临时 iframe、写入同一套 HTML 再打印,与模板预览常用做法一致。
*/
function handleBrowserPrint() {
const html = previewHtml.value;
if (!html?.trim()) {
createMessage.warning('预览未就绪,请稍后再试');
return;
}
const iframe = document.createElement('iframe');
iframe.setAttribute(
'style',
'position:fixed;left:0;top:0;width:0;height:0;border:0;opacity:0;pointer-events:none;',
);
document.body.appendChild(iframe);
const doc = iframe.contentDocument;
if (!doc) {
document.body.removeChild(iframe);
createMessage.error('无法创建打印文档');
return;
}
try {
doc.open();
doc.write(html);
doc.close();
} catch {
document.body.removeChild(iframe);
createMessage.error('写入打印内容失败');
return;
}
const cleanup = () => {
try {
if (iframe.parentNode) {
document.body.removeChild(iframe);
}
} catch {
// ignore
}
};
const runPrint = () => {
try {
const w = iframe.contentWindow;
if (!w) {
createMessage.error('无法唤起打印窗口');
cleanup();
return;
}
w.focus();
w.print();
/** 关闭打印对话框后移除临时 iframe部分浏览器支持 afterprint */
w.addEventListener('afterprint', cleanup, { once: true });
window.setTimeout(cleanup, 120000);
} catch {
createMessage.error('无法唤起打印,请检查浏览器弹窗/打印权限');
cleanup();
}
};
/** 等待排版与字体后再打印,减少空白页 */
window.setTimeout(runPrint, 100);
}
function onClose() {
errorText.value = '';
previewHtml.value = '';
}
watch(
() => [props.open, props.cardId] as const,
([isOpen, id]) => {
if (isOpen && id) {
void loadPreview(id);
}
if (!isOpen) {
onClose();
}
},
);
</script>
<style lang="less" scoped>
.preview-error {
color: #cf1322;
padding: 8px 0;
}
.preview-body {
min-height: 420px;
max-height: 72vh;
overflow: auto;
border: 1px solid #f0f0f0;
border-radius: 4px;
background: #fafafa;
}
.preview-iframe {
display: block;
width: 100%;
min-height: 400px;
border: 0;
background: #fff;
}
.preview-footer {
margin-top: 16px;
padding-top: 12px;
border-top: 1px solid #f0f0f0;
text-align: right;
}
</style>