在前端开发中,除了将数据呈现后,我们往往需要为用户提供,打印,导出等能力,导出是为了存档或是二次分析,而打印则因为很多单据需要打印出来作为主要的单据来进行下一环节的票据支撑, 而前端打印可以说是非常令人头疼的一件事。
为什么令大家头疼呢?
因为前端打印,要强依赖与浏览器的打印预览页面,会天然存在以下弊端:
每一次打印都要弹出来打印预览对话框,如果前端需要批量打印,那么意味着客户要点击无数个关闭按钮,才能实现批量打印,如果一次性打印几百张上千张的报表,则会成为“NightMare”。
前端打印强依赖于浏览器,主流的思路是先将内容转换为PDF文件,再调用浏览器的打印功能进行打印,而生成PDF文件是依赖于浏览器对于字体,边线等的处理,因此浏览器的异同则直接导致打印出来的效果差距很大,有的边线加粗,有的1页数据,打印出来呈现2页,也是让开发者十分苦恼的事情,对于一些打印要求比较高的行业,这就是灾难。
因此如何在前端实现无预览打印,也就是用户点击打印之后直接就使用默认打印机打印出来。针对这个需求,我们验证了一个解决该问题的方案,本贴就来介绍该方案如何实现。
实现思路如下:
后端实现一个接口,接收Blob类型PDF流,然后调用系统默认打印机,将PDF进行静默打印。
前端利用ACTIVEREPORTSJS自带的导出PDF,导出Blob类型,然后通过POST请求调用后端接口将Blob流传给后端进行打印。
具体实现步骤:
前端实现方法:
前端利用ActivereportsJS的PDF.exportDocument无预览导出PDF,该接口返回的result包含data属性和download方法,然后调用后端接口,将result.data传递给后端。
functionprintPDF() {
varACTIVEREPORTSJS = GC.ActiveReports.Core;
varPDF = GC.ActiveReports.PdfExport;
var settings = {
info: {
title: "test",
author: "GrapeCity inc.",
},
pdfVersion: "1.7",
};
var pageReport = newACTIVEREPORTSJS.PageReport();
pageReport
.load("1.rdlx-json")
.then(function () {
return pageReport.run();
})
.then(function (pageDocument) {
returnPDF.exportDocument(pageDocument, settings);
})
.then(function (result) {
let formData = newFormData();
formData.append("file", result.data);
fetch("http://localhost:8088/print", {
method: 'POST',
mode: 'cors',
body: formData
})
});
}
后端实现方式:
我这边是采用python实现了一个接口,接收前端传递的Blob文件流,然后调用后端部署的服务器默认打印机直接进行静默打印。
后端程序可以部署到服务器上,如果是windows服务器,可以直接下载exe,在服务器上运行。
下载下来是2个exe程序,需要放在同一个文件夹,然后运行PrintAgent.exe,切记这两个程序需要放在同一个文件夹。
注意:如果exe只给服务器上部署,那么前端在打印时调用服务器地址接口打印,最终都会从服务器上连接的打印机打出来。
如果exe给客户端部署了,那么前端打印就可以代码调用localhost地址去打印,最终就会从客户端所连接的默认打印机打印出来;
切换打印机的话,就调整windows的默认打印机就可以。
Linux服务器的话需要将源码拷贝到服务器去运行。
在实现 cli 的过程中会涉及到组件名称命名方式的转换、执行cmd命令等操作,所以在开始实现创建组件前,先准备一些工具类。
在 cli/src/util/ 目录上一篇文章中已经创建了一个 log-utils.ts 文件,现继续创建下列四个文件:cmd-utils.ts、loading-utils.ts、name-utils.ts、template-utils.ts
1.1 name-utils.ts
该文件提供一些名称组件转换的函数,如转换为首字母大写或小写的驼峰命名、转换为中划线分隔的命名等:
/**
* 将首字母转为大写
*/exportconst convertFirstUpper = (str: string): string => {
return`${str.substring(0, 1).toUpperCase()}${str.substring(1)}`
}
/**
* 将首字母转为小写
*/exportconst convertFirstLower = (str: string): string => {
return`${str.substring(0, 1).toLowerCase()}${str.substring(1)}`
}
/**
* 转为中划线命名
*/exportconst convertToLine = (str: string): string => {
returnconvertFirstLower(str).replace(/([A-Z])/g, '-$1').toLowerCase()
}
/**
* 转为驼峰命名(首字母大写)
*/exportconst convertToUpCamelName = (str: string): string => {
let ret = ''const list = str.split('-')
list.forEach(item => {
ret += convertFirstUpper(item)
})
returnconvertFirstUpper(ret)
}
/**
* 转为驼峰命名(首字母小写)
*/exportconst convertToLowCamelName = (componentName: string): string => {
returnconvertFirstLower(convertToUpCamelName(componentName))
}
1.2 loading-utils.ts
在命令行中创建组件时需要有 loading 效果,该文件使用 ora 库,提供显示 loading 和关闭 loading 的函数:
import ora from'ora'letspinner: ora.Ora | null = nullexportconstshowLoading = (msg: string) => {
spinner = ora(msg).start()
}
exportconstcloseLoading = () => {
if (spinner != null) {
spinner.stop()
}
}
1.3 cmd-utils.ts
该文件封装 shelljs 库的 execCmd 函数,用于执行 cmd 命令:
import shelljs from'shelljs'import { closeLoading } from'./loading-utils'exportconstexecCmd = (cmd: string) => newPromise((resolve, reject) => {
shelljs.exec(cmd, (err, stdout, stderr) => {
if (err) {
closeLoading()
reject(newError(stderr))
}
returnresolve('')
})
})
1.4 template-utils.ts
由于自动创建组件需要生成一些文件,template-utils.ts 为这些文件提供函数获取模板。由于内容较多,这些函数在使用到的时候再讨论。
2 参数实体类
执行 gen 命令时,会提示开发人员输入组件名、中文名、类型,此外还有一些组件名的转换,故可以将新组件的这些信息封装为一个实体类,后面在各种操作中,传递该对象即可,从而避免传递一大堆参数。
2.1 component-info.ts
在 src 目录下创建 domain 目录,并在该目录中创建 component-info.ts ,该类封装了组件的这些基础信息:
import * as path from'path'import { convertToLine, convertToLowCamelName, convertToUpCamelName } from'../util/name-utils'import { Config } from'../config'exportclassComponentInfo {
/** 中划线分隔的名称,如:nav-bar */lineName: string/** 中划线分隔的名称(带组件前缀) 如:yyg-nav-bar */lineNameWithPrefix: string/** 首字母小写的驼峰名 如:navBar */lowCamelName: string/** 首字母大写的驼峰名 如:NavBar */upCamelName: string/** 组件中文名 如:左侧导航 */zhName: string/** 组件类型 如:tsx */type: 'tsx' | 'vue'/** packages 目录所在的路径 */parentPath: string/** 组件所在的路径 */fullPath: string/** 组件的前缀 如:yyg */prefix: string/** 组件全名 如:@yyg-demo-ui/xxx */nameWithLib: stringconstructor (componentName: string, description: string, componentType: string) {
this.prefix = Config.COMPONENT_PREFIXthis.lineName = convertToLine(componentName)
this.lineNameWithPrefix = `${this.prefix}-${this.lineName}`this.upCamelName = convertToUpCamelName(this.lineName)
this.lowCamelName = convertToLowCamelName(this.upCamelName)
this.zhName = description
this.type = componentType === 'vue' ? 'vue' : 'tsx'this.parentPath = path.resolve(__dirname, '../../../packages')
this.fullPath = path.resolve(this.parentPath, this.lineName)
this.nameWithLib = `@${Config.COMPONENT_LIB_NAME}/${this.lineName}`
}
}
2.2 config.ts
上面的实体中引用了 config.ts 文件,该文件用于设置组件的前缀和组件库的名称。在 src 目录下创建 config.ts:
exportconstConfig = {
/** 组件名的前缀 */COMPONENT_PREFIX: 'yyg',
/** 组件库名称 */COMPONENT_LIB_NAME: 'yyg-demo-ui'
}
3 创建新组件模块
3.1 概述
上一篇开篇讲了,cli 组件新组件要做四件事:
创建新组件模块;
创建样式 scss 文件并导入;
在组件库入口模块安装新组件模块为依赖,并引入新组件;
创建组件库文档和 demo。
本文剩下的部分分享第一点,其余三点下一篇文章分享。
在 src 下创建 service 目录,上面四个内容拆分在不同的 service 文件中,并统一由 cli/src/command/create-component.ts 调用,这样层次结构清晰,也便于维护。
首先在 src/service 目录下创建 init-component.ts 文件,该文件用于创建新组件模块,在该文件中要完成如下几件事:
创建新组件的目录;
使用 pnpm init 初始化 package.json 文件;
修改 package.json 的 name 属性;
安装通用工具包 @yyg-demo-ui/utils 到依赖中;
创建 src 目录;
在 src 目录中创建组件本体文件 xxx.tsx 或 xxx.vue;
在 src 目录中创建 types.ts 文件;
创建组件入口文件 index.ts。