在我们组开发的业务系统中,存在文书种类多、格式不一的场景,但又要求保持一致的打印体验,怎么办呢?难道每次加一种新文书就写一套打印逻辑?不存在的。用「配置 + 动态模板 + iframe 打印」的思路,可以搭出一套一个组件打天下的通用打印方案。
一、先想清楚:我们要解决什么问题?
- 多种文书:不同业务对应不同的文书模板,字段、布局、样式都不一样。
- 统一入口:希望小伙伴调用时只关心「打开打印、传文书类型和业务单号」,不用关心具体模板和接口。
- 可编辑再打:部分文书需要在预览里编辑或填充后再打印,而不是纯静态展示。
- 打印体验:要能控制打印样式(页眉页脚、分页、字体),并且不把整页 UI 一起打出去。
二、整体架构:三层拆解
可以把通用打印拆成三层,逻辑会非常清晰:
- 主组件:包含组件状态提示、调用 iframe 执行打印等功能;
- 配置层:文书类型与文书模版要一一对应;
- 模板层:每种文书一个 Vue 模板组件,负责展示、编辑字段,同时提供方法给壳层拿去保存/打印。
三、配置层:文书类型与模板的映射
用一份配置集中维护,后续扩展新文书主要就是:加一条配置 + 加一个模板组件。
javascript
export const DOC_TYPE = {
FORM_A: 'FORM_A', // 例如:某登记表
FORM_B: 'FORM_B', // 例如:某告知书
// ...
};
export const documentTemplates = {
[DOC_TYPE.FORM_A]: {
title: '某登记表',
},
[DOC_TYPE.FORM_B]: {
title: '某告知书',
},
};
export function getTemplateConfig(docType) {
const config = documentTemplates[docType];
if (!config) {
console.warn(`未找到文书类型 ${docType} 的模板配置`);
return null;
}
return config;
}
主组件里使用 getTemplateConfig(docType) 拿配置,这样「加新文书」对主组件来说就是多一个配置键和对应的模板组件啦。
四、壳层:动态组件 + 打印流程
主组件只认「当前 docType 对应哪个模板组件」,用 component :is 动态渲染,这样无需在壳里写一长串 if/else 或 v-if。
4.1 模板区域与动态组件
vue
<!-- 打印区域:唯一 id 便于后面克隆到 iframe -->
<div id="commonPrintArea" class="print-area">
<component
:is="templateComponent"
ref="templateRef"
:data="printData"
:numb="numb"
:template-config="templateConfig"
/>
</div>
javascript
computed: {
templateComponent() {
const componentMap = {
FORM_A: 'FormATemplate',
FORM_B: 'FormBTemplate',
// 新文书:加一行即可
};
return componentMap[this.docType] || null;
},
},
printData 由你在 init/loadCommonData 里请求接口或直接使用外部传入的数据;templateConfig 来自 getTemplateConfig(this.docType)。
4.2 从模板组件拿数据:约定 getData()
打印或保存前,主组件需要拿到当前模板里用户可能改过的内容,所以约定:每个模板组件暴露 getData()方法,返回要落库/打印的纯数据。
javascript
// 主组件 methods
getTemplateData() {
const templateComponent = this.$refs.templateRef;
if (!templateComponent || typeof templateComponent.getData !== 'function') {
return null;
}
return templateComponent.getData();
},
async handlePrint() {
const templateData = this.getTemplateData();
if (!templateData) return;
const saved = await this.savePrintRecord(templateData);
if (!saved) return;
this.executePrint();
this.$emit('print-success', { docType: this.docType, numb: this.numb, printData: templateData });
}
这样无论是「先保存再打」还是「仅打印」,数据源都统一来自模板的 getData()。
五、模板层:可编辑字段与 getData()
模板里会有大量「看起来像下划线填空」的格子,既要可编辑又要打印时样式干净,我们的做法是,用一个可编辑字段的子组件 包一层,再在模板里用 v-model 绑定 editableData对象,最后 getData() 直接返回这个对象。
5.1 可编辑字段的子组件(EditableField组件)
用 HTML5的contenteditable属性做内联编辑,通过 v-model和父组件同步;输入法期间用 compositionstart/end 防抖。
vue
<template>
<span
ref="editableElement"
:class="['editable-field', customClass]"
:contenteditable="editable"
:data-placeholder="placeholder"
@blur="handleBlur"
@input="handleInput"
@compositionstart="isComposing = true"
@compositionend="isComposing = false; handleInput($event)"
/>
</template>
<script>
export default {
name: 'EditableField',
props: ['value', 'editable', 'placeholder', 'customClass', 'maxlength'],
data() {
return { isComposing: false, innerValue: '' };
},
watch: {
value: {
immediate: true,
handler(newVal) {
if (!this.isComposing && newVal !== this.innerValue) {
this.innerValue = newVal || '';
if (this.$refs.editableElement) this.$refs.editableElement.innerText = this.innerValue;
}
},
},
},
methods: {
handleBlur(e) {
const text = e.target.innerText.trim();
this.innerValue = text;
this.$emit('input', text);
},
handleInput(e) {
if (this.isComposing) return;
let text = e.target.innerText;
if (this.maxlength && text.length > this.maxlength) {
text = text.substring(0, this.maxlength);
this.$refs.editableElement.innerText = text;
}
this.innerValue = text;
this.$emit('input', text);
},
},
};
</script>
模板里用法示例:
vue
<editable-field v-model="editableData.name" placeholder="请输入" custom-class="inline-underline-field" />
打印样式里对 .editable-field、.inline-underline-field 等做「无边框、无背景、保下划线」的覆盖,即可做到「屏幕可编辑、纸上像填空」。
六、iframe 打印:只打「这一块」且样式可控
直接 window.print() 会连侧边栏、导航、按钮一起打。我们的做法是:把要打印的那块 DOM 克隆到隐藏的 iframe 里,在 iframe 里注入完整打印样式,再对 iframe 执行 print()。
6.1 克隆 + 处理特殊节点(如复选框)
克隆时注意:像 Element UI 的 checkbox,在 iframe 里可能不会按「勾选状态」渲染,所以克隆后先把这类控件转成「勾选用 ☑ / 未勾选用 ☐」的纯文本,再塞进 iframe,这样打印出来稳定一致。
javascript
processCheckboxes(container) {
container.querySelectorAll('.el-checkbox').forEach((el) => {
const input = el.querySelector('input[type="checkbox"]');
const isChecked = input && input.checked;
const checkmark = document.createElement('span');
checkmark.textContent = isChecked ? '☑' : '☐';
// 若有 .el-checkbox__label,可把 label 文本和 checkmark 拼成新节点替换 el
el.parentNode.replaceChild(checkmark, el);
});
}
6.2 创建 iframe 并写入 HTML + 样式
javascript
executePrint() {
const printArea = document.getElementById('commonPrintArea');
if (!printArea) return;
const cloned = printArea.cloneNode(true);
this.processCheckboxes(cloned);
const iframe = document.createElement('iframe');
iframe.style.cssText = 'position:fixed;right:0;bottom:0;width:0;height:0;border:none';
document.body.appendChild(iframe);
const printStyles = this.getPrintStyles(); // 见下一小节
const doc = iframe.contentWindow.document;
doc.open();
doc.write(`
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>${this.templateConfig.title}</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: "Microsoft YaHei", Arial, sans-serif; line-height: 1.5; color: #000; background: #fff; }
${printStyles}
</style>
</head>
<body>${cloned.innerHTML}</body>
</html>
`);
doc.close();
iframe.onload = () => {
iframe.contentWindow.focus();
setTimeout(() => {
iframe.contentWindow.print();
setTimeout(() => document.body.removeChild(iframe), 500);
}, 100);
};
}
这样只有 iframe 里的 body 被打印,且样式完全由你注入的 printStyles 控制。
七、打印样式:基础 + 按文书类型扩展
拆成「基础样式(所有文书共用)」和「按 docType 的扩展样式」,主组件里根据 docType 拼成最终样式字符串。
javascript
getPrintStyles() {
const baseStyles = `
@page { margin: 0; size: A4; }
body { margin: 10mm 10mm 15mm 10mm; font-family: "仿宋", serif; }
.form-table { width: 100%; border-collapse: collapse; border: 2px solid #000; }
.form-table th, .form-table td { border: 1px solid #000; padding: 6px 8px; }
.form-table tr { page-break-inside: avoid; }
.editable-field { border: none !important; background: transparent !important; box-shadow: none !important; }
.inline-underline-field { border-bottom: 1px solid #333 !important; min-height: 1.2em; }
`;
const docTypeStyles = this.getDocTypeSpecificStyles(); // 从 styleMap[docType] 取
return `${baseStyles}\n${docTypeStyles}`;
}
新增文书时,如需单独调表格列宽、标题字号等,在 getDocTypeSpecificStyles() 的 styleMap 里加一条即可,主组件逻辑不用改。
结尾:按这套思路实现后,业务侧只需要「传 docType + 外部数据) + 监听事件」,就能接住多种文书、可编辑、可保存的通用打印能力啦;后续加新文书也不会再在主组件里堆逻辑,维护成本也会低很多。