新增打印模板绑定功能,支持业务与打印模板的映射配置。实现打印模板的增删改查操作,优化打印数据的生成逻辑,提升打印模板的灵活性和用户体验。同时,新增打印机查询接口,增强打印服务的可用性和实时性。
This commit is contained in:
@@ -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 });
|
||||
@@ -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 },
|
||||
];
|
||||
414
jeecgboot-vue3/src/views/print/bizTemplateBind/index.vue
Normal file
414
jeecgboot-vue3/src/views/print/bizTemplateBind/index.vue
Normal 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>
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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'));
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 + 前端生成的 pdfBase64;printerName 空则用默认队列 */
|
||||
export const printPdf = (data: { id: string; printerName?: string; pdfBase64: string; fileName?: string }) =>
|
||||
defHttp.post({ url: Api.printPdf, data, timeout: 3 * 60 * 1000 });
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user