likes
comments
collection
share

Electron 收银台重构小结

作者站长头像
站长
· 阅读数 66

上一篇我写了一个基于 Tauri 框架的收银台前台项目,由于 Rust 底层实在是学的有点痛苦,再加上框架本身对于很多地方的支持都还不成熟,社区方案也不全面,所以我就一狠心使用 Electron 重写了该项目,现在已经打包进入测试的流程了,特此做一下记录和总结。

技术栈

Electron + Vue3 + element-plus + vite

难点

业务层面还是之前的那套业务,基本上没有什么问题,而且之前也是用 Vue3 + element-plus 写的,所以页面很容易就复制过来了,整个开发过程中,遇到的比较困难的点,主要有以下那么几个

调用32位DLL文件

这个需求不管在 Tauri 还是在 Electron 里面都是一个复杂的实现, Tauri 里面,由于社区没有那么的程序,所以使用底层 Rust 自行编写,对于我这个前端开发来说,还是过于复杂了。

而在 Electron 这里,经过网络搜索,还是找到了一种相对简单的实现方案 —— Koffi

官方文档地址:koffi.dev/

中文文档地址:nongchatea.gitbook.io/koffi-chine…

具体的使用方法,文档里面教的已经十分详细了,我这里只提几个我遇到的问题。

  1. DLL文件打包

切记在打包的时候需要把 DLL 文件单独打包到某个文件夹底下,这里我们以 resources 文件夹为例。

首先在项目的根目录下建一个 resources 文件夹,在其中放入我们需要加载的 DLL 文件

然后在 main.js 中调用的时候,DLL 文件路径的获取可以这么写

let mwrf32_path = ''
if (process.env.NODE_ENV === 'development') {
    mwrf32_path = path.join(__dirname, 'resources/mwrf32.dll')
  } else {
    mwrf32_path = './resources/mwrf32.dll'
  }

然后在 package.json 中我们需要对打包进行一下配置,主要目的是让打包安装后的应用程序中的 resources 目录下,有我们需要加载的 DLL 文件,这里我采用的是 electron-builder 进行的打包,所以配置是这样的

{
	...
	"build": {
		...
		"extraResources": {
      "from": "resources",
      "to": "./"
    },
    ...
	},
	...
}
  1. 32位DLL文件的加载

想要加载32位的DLL文件,必须得保证你 build 出来的程序是32位的,而且在开发的时候想要调用成功,还必须保证当前环境使用的 node 版本是32位的,这两个解决方法都很简单,网上一搜一大把,我就不细说了,但是必须得保证两个都得是32位的,缺一不可。

这样我们就能在开发和生产环境中,都能调用自己想要的32位 DLL 文件了。

打印小票

之前做过一个提供给供应商用的简单的分拣系统,里面有个打印商品标签的功能,不过那个数据格式很简单,所以逻辑组织起来就很简单

ipcMain.handle('printTest', async (event, printer) => {
    const printWindow = new BrowserWindow({
      // 实例化一个新的浏览器窗口用于打印
      webPreferences: {
        nodeIntegration: true,
        webSecurity: false,
        enableRemoteModule: true,
      },
      show: false,
      fullscreenable: true,
      minimizable: false,
    })
    // 打印窗口添加html页面
    printWindow.loadURL(
      `data:text/html;charset=utf-8,${encodeURI(
        `<!doctype html>
          <html>
            <style>@page{margin: 0}div{margin:0;font-size:16px;font-weight:400;line-height:20px;color:black}</style>
            <body>
              <div>序号:1</div>
              <div>时间:${moment().format('YYYY-MM-DD HH:mm:ss')}</div>
              <div style="font-size:24px !important;line-height:28px !important;font-weight:600 !important">测试商品</div>
              <div style="font-size:24px !important;line-height:28px !important;font-weight:600 !important">2斤</div>
            </body>
          </html>`,
      )}`,
    )
    printWindow.webContents.once('did-finish-load', () => {
      // 等待页面加载完成过后再打印
      printWindow.webContents.print(
        {
          silent: true, // 不显示打印对话框
          printBackground: false, // 是否打印背景图像
          deviceName: printer.name,
          pageSize: {
            width: 60000,
            height: 40000
          },
          copies: 1
        },
        (success, failureReason) => {
          mainWindow.webContents.send('res', { success, failureReason })
          printWindow.close() // 打印过后关闭该窗口
        },
      )
    })
  })

这次这个小票打印数据更多,格式更复杂一点,并且还要在最上方打印单号的 barcode,所以需要单独加载一个静态 html 文件来进行打印

由于我们要保证页面上内容打印的时候不能缺失,所以一是要确保打印内容全部渲染结束后,才进行打印,二是保证内容全部渲染结束后,再把整个打印部分的高度传回给打印逻辑来组织打印页面的高度,这样才能保证打印出来的小票内容是完整的并且不会被截断。

这里我们用一个单独的静态 html 页面来做打印的页面,这样比较容易控制打印的样式和 barcode 的生成。

<!-- /public/pay.html -->
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <title>Print preview</title>
    <style>
      @page {
        size: auto;
      }
      html {
        background-color: #ffffff;
        margin: 0px;
        color: black;
        padding: 0;
      }
      body {
        margin: 0;
        padding: 0;
      }
      body,
      html {
        font-size: 10px;
        font-weight: lighter;
      }
      table {
        width: 100%;
        border-collapse: collapse;
        margin: 5px 0px;
      }
      table th,
      table td {
        text-align: center;
        padding: 5px;
      }
      table {
        border-bottom: 1px dashed #000;
        border-top: 1px dashed #000;
      }
    </style>
  </head>

  <body>
    <div>
      <svg id="barcode" style="margin: 0 auto"></svg>
      <div id="printContent" style="text-align: center;margin: 0 auto;padding-right: 10px;"></div>
    </div>
    <script src="<https://cdn.bootcdn.net/ajax/libs/jsbarcode/3.11.5/JsBarcode.all.min.js>"></script>
    <script src="<https://cdn.bootcdn.net/ajax/libs/moment.js/2.29.4/moment.min.js>"></script>
    <script type="text/javascript">
      window.electron.messagePayReady()
      window.electron.payOn((e, data) => {
        JsBarcode('#barcode', data.id, {
          width: 1,
          height: 40,
          displayValue: true,
          fontSize: 12
        })
        document.getElementById('printContent').innerHTML = `
            <div style="font-size: 18px;padding: 10px 0px;font-weight: normal">${data.store_name}</div>
            <div style="text-align: left">${data.id}</div>
            <div style="text-align: left">${moment().format('YYYY-MM-DD HH:mm:ss')}</div>
            <div style="text-align: left">收银员:${data.user}</div>
            <table>
                <tr>
                    <td>品名</td>
                    <td>单价</td>
                    <td>数量</td>
                    <td>小计</td>
                </tr>
                ${data.carts.map(
                  (item) => `<tr>
                        <td>${item.name}</td>
                        <td>${item.price}</td>
                        <td>${item.number}</td>
                        <td>${item.amount}</td>
                        </tr>`
                )}
            </table>
            <div style="display:flex; aligm-items: center;justify-content: space-between;">
                <div>合计:${data.total_amount}</div>
                <div>数量:${data.count}</div>
                </div>
                ${
                  data.cash !== '0.00'
                    ? `<div style="display:flex; aligm-items: center;justify-content: space-between;">
                    <div>付款:现金</div>
                    <div>${data.cash}</div>
                    </div>${
                      data.change !== '0.00'
                        ? `<div style="text-align: left">
                        找零:${data.change}
                        </div>`
                        : ''
                    }`
                    : data.scan !== '0.00'
                      ? `<div style="display:flex; aligm-items: center;justify-content: space-between;">
                    <div>付款:扫码付</div>
                    </div>`
                      : ''
                }
                ${
                  data.balance.amount !== '0.00'
                    ? `<div style="display:flex; aligm-items: center;justify-content: space-between;">
                    <div>付款:储值卡[${data.balance.member_name}]</div>
                    </div>
                    <div style="text-align: left">储值卡号:${data.balance.member_card_id}</div>
                    <div style="text-align: left">储值余额:${data.balance.member_balance}</div>
                    `
                    : ''
                }
        `
        window.electron.messagePaySend({
          width: document.body.offsetWidth,
          height: document.body.offsetHeight + 100
        })
      })
    </script>
  </body>
</html>

为了确保打印内容的完整性,这里我们要用到 Electron 的事件通知,和主进程做交互,因为需要打印的内容是从主进程来的。

// main.js
...

function createWindow() {
  // Create the browser window.
  const mainWindow = new BrowserWindow({
    width: 800, // 窗口宽度
    height: 600, // 窗口高度
    // title: "Electron app", // 窗口标题,如果由loadURL()加载的HTML文件中含有标签<title>,该属性可忽略
    icon: nativeImage.createFromPath('public/favicon.ico'), // "string" || nativeImage.createFromPath('public/favicon.ico')从位于 path 的文件创建新的 NativeImage 实例
    webPreferences: {
      // 网页功能设置
      webviewTag: true, // 是否使用<webview>标签 在一个独立的 frame 和进程里显示外部 web 内容
      webSecurity: false, // 禁用同源策略
      preload: path.join(__dirname, 'preload.js'),
      nodeIntegration: true, // 是否启用node集成 渲染进程的内容有访问node的能力,建议设置为true, 否则在render页面会提示node找不到的错误
      nodeIntegrationInSubFrames: true
    }
  })

  ...
  
  function printOrder(printer, order) {
    const printWindow = new BrowserWindow({
      // 实例化一个新的浏览器窗口用于打印
      webPreferences: {
        nodeIntegration: true,
        webSecurity: false,
        enableRemoteModule: true,
        preload: path.join(__dirname, 'preload.js')
      },
      show: false,
      fullscreenable: true,
      minimizable: false
    })

    // 打印窗口添加html页面
    printWindow.webContents.loadURL(
      process.env.NODE_ENV === 'development'
        ? '<http://localhost:5173/public/pay.html>'
        : url.format({
            pathname: path.join(__dirname, 'dist/pay.html'),
            protocol: 'file:',
            slashes: true
          })
    )
    
    // 监听打印页面是否加载完毕,加载完毕则将需要的数据发送给页面
    printWindow.webContents.ipc.on('message:payReady', () => {
      printWindow.webContents.send('message:pay', order)
    })
    
    // 监听需要打印的数据是否在页面上完整渲染,渲染结束后则将打印页面高度回传并开始打印
    printWindow.webContents.ipc.on('message:toPay', (e, data) => {
      let height = Math.ceil((data.height + 60) * 264.5833)
      setTimeout(() => {
        printWindow.webContents.print(
          {
            silent: true, // 不显示打印对话框
            deviceName: printer,
            margins: {
              marginType: 'none'
            },
            printBackground: false,
            pageSize: {
              width: 58000,
              height
            },
            copies: 1
          },
          (success, failureReason) => {
            mainWindow.webContents.send('res', { success, failureReason })
            printWindow.close() // 打印过后关闭该窗口
          }
        )
      }, 300)
    })

    // printWindow.webContents.openDevTools({ mode: 'detach' })
  }

  ipcMain.handle('print', (event, printer, order) => {
    printOrder(printer, order)
  })

  ...
}

app.allowRendererProcessReuse = true

// This method will be called when Electron has finished
// initialization and is ready to create browser windows.
// Some APIs can only be used after this event occurs.
app.whenReady().then(() => {
  createWindow()
})

// Quit when all windows are closed.
app.on('window-all-closed', function () {
  // On macOS it is common for applications and their menu bar
  // to stay active until the user quits explicitly with Cmd + Q
  if (process.platform !== 'darwin') app.quit()
})

app.on('activate', function () {
  // On macOS it's common to re-create a window in the app when the
  // dock icon is clicked and there are no other windows open.
  if (BrowserWindow.getAllWindows().length === 0) createWindow()
})

// In this file you can include the rest of your app's specific main process
// code. You can also put them in separate files and require them here.
// preload.js
// eslint-disable-next-line @typescript-eslint/no-var-requires
const { contextBridge, ipcRenderer, remote, shell } = require('electron')

window.addEventListener('DOMContentLoaded', () => {
  const replaceText = (selector, text) => {
    const element = document.getElementById(selector)
    if (element) element.innerText = text
  }

  for (const dependency of ['chrome', 'node', 'electron']) {
    replaceText(`${dependency}-version`, process.versions[dependency])
  }
})

contextBridge.exposeInMainWorld('electron', {
  ipcRenderer,
  remote,
  shell,
  ...
  payOn: (callback) => ipcRenderer.once('message:pay', callback),
  messagePaySend: (...args) => ipcRenderer.send('message:toPay', ...args),
  messagePayReady: (...args) => ipcRenderer.send('message:payReady', ...args),

})

Win7支持

这个就更简单一点,就是找一下 Electron 从什么版本开始不支持 win7了,然后用之前的最新版就行,我现在使用的就是 21.4.4 这个版本,这样打出来的包就能在 win7 上安装了,不然会报一些缺少 DLL 文件的错误。

转载自:https://juejin.cn/post/7345647273160867881
评论
请登录