效率提升-客户端管理程序

155 阅读6分钟

背景

  前面介绍了包括书签管理,Markdown管理系统等,属于独立的工具。日常工作中也经常会借助一些第三方辅助工具。比如:定时提醒工具,用于提醒自己写周报,开会,沟通交流等等;在线网盘,在线存储一些资料(保证安全的前提下);图床工具,辅助图片插入markdown中;代码生成器,辅助生成固定代码模板;以及包括ico图片生成,json格式化等等

  这些工具分散在不同的角落,有些是C/S工具,有些是互联网上在线操作,每次使用都感觉很凌乱。这时候就在琢磨了,我自己能不能写一个工具,把这些工具都集成起来。

需求

  我自己个人的习惯,在做事情之前,需要把事情先分析一下,我做这个工具目的是什么,要解决什么问题,能带来什么帮助。最后总结出我想要达到的效果。

  1. 工具必须是跨平台的,我有开发电脑(Windows),会议、交流电脑(MacOS)的,我想这个工具在所有电脑上都可以使用
  2. 数据存在互联网云端,这样工具到那都可以正常使用
  3. 数据传输需要安全,比如网盘功能,存储和传输就必须要安全,除了https通道加密以外,数据本身也要加密
  4. 工具可以常驻通知区域,这样不用频繁开关(类似QQ)
  5. 工具支持自定义快捷方式,只要工具挂在那,不用打开,通过快捷方式就能操作某些功能。
  6. 工具颜值要稍微高点,不能太Low

经过今天的技术选型和分析以后,终于是选定了Electron + Vue作为开发技术。

前面也介绍到过,这个选型不仅仅是因为工具需要,我们公司内部还有很多客户端软件,因为甲方属于政府行业,最近几年一直在搞终端国产化,我们的工具本身也面临转型。用QT这种门槛比较高,维护成本比较大。所以先行研究下这个技术应用的可行性。

技术介绍

  Electron 是 GitHub 开发的一个开源框架。它允许使用 Node.js(作为后端)和 Chromium(作为前端)完成桌面 GUI 应用程序的开发。Electron 可以用于构建具有 html、css、JAVAScript 的跨平台桌面应用程序,它通过将 Chromium 和 node.js 合同一个运行的环境中来实现这一点,应用程序可以打包到 mac、windows 和 linux 系统上。

  总的来说,就是Electron提供一个底座,用于和操作系统交互(包括打印机、控制台、提醒等等),实际内部都是html页面。

最终是通过 Electron + Vue + Ant Design Vue 来实现整个功能。

Electron主进程

  主进程其实就是前面提到的底座,然后就是创建一个底座容器 createWindow

<!--全局入口页面-->
<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8">
    <title>管理小工具</title>
    <% if (htmlWebpackPlugin.options.nodeModules) { %>
      <!-- Add `node_modules/` to global paths so `require` works properly in development -->
      <script>
        require('module').globalPaths.push('<%= htmlWebpackPlugin.options.nodeModules.replace(/\\/g, '\\\\') %>')
      </script>
    <% } %>
  </head>
  <body>
    <div id="app"></div>
    <!-- Set `__static` path to static files in production -->
    <% if (!process.browser) { %>
      <script>
        if (process.env.NODE_ENV !== 'development') window.__static = require('path').join(__dirname, '/static').replace(/\\/g, '\\\\')
      </script>
    <% } %>

    <!-- webpack builds are automatically injected -->
  </body>
</html>


//index.js
app.on('ready', () => {
    //创建主窗体
    createWindow();
    //初始化通知区域图标
    initTrayIcon();
    // 隐藏菜单栏
    const {Menu} = require('electron');
    Menu.setApplicationMenu(null);
    // process.platform就是平台类型, windows,darwin(macos), linux
    if (process.platform === 'darwin') {
        app.dock.hide();
    }
})

//程序退出,关闭等事件处理
app.on('window-all-closed', () => {
    app.quit()
})
app.on('will-quit', function () {
    globalShortcut.unregisterAll()
})
app.on('activate', () => {
    if (mainWindow === null) {
        createWindow()
    }
})
let mainWindow
//渲染进程(vue页面)地址,调试阶段是http地址,发布阶段就是index.html
const winURL = process.env.NODE_ENV === 'development'
    ? `http://localhost:9088`
    : `file://${__dirname}/index.html`
function createWindow() {
    //创建主窗体,可以设置大小,图标等
    mainWindow = new BrowserWindow({
        height: 700,
        useContentSize: true,
        width: 1200,
        minWidth: 1200,
        minHeight: 700,
        resizable: true,
        icon: __static + '/image/tool.png',
        webPreferences: {
            nodeIntegration: true,
            contextIsolation: false,
            enableRemoteModule: true
        }// show: false
    })

    //加载渲染进程地址(vue发布的页面)
    mainWindow.loadURL(winURL)

    //打开调试对话框,渲染进程其实就是html页面,所以打开调试对话框,其实就是调试前端的时候按F12
    if (process.env.NODE_ENV === "development") {
        mainWindow.webContents.openDevTools();
    } else {
        //隐藏任务栏,我只需要通知区域图标就行了,所以就隐藏了
        mainWindow.setSkipTaskbar(true);
    }

    mainWindow.on('close', (e) => {
        if (!canQuit) {
            e.preventDefault();
            mainWindow.hide();
        }
    })

    //注册快捷方式
    const shortArray = db.getShortCutData();
    for (let i = 0; i < shortArray.length; i++) {
        registerShortCut(shortArray[i]["key"], shortArray[i]["shortcut"])
    }
}

通知区域图标

  前面提到,我需要像QQ一样,把程序挂在右下角的通知区域(Tray),所以这块也需要处理下。主进程启动的时候,也会调用initTrayIcon 方法来初始化通知区域图标。

/**
 * 这里定义let tray = null在外面,也就是全局,是必须要这么去弄的
 * 如果定义在方法体内,后续这个tray对象会被自动回收掉,你看到的效果就是发现通知区域图标没了
 * 定义在全局的话,在整个生命周期内,它都不会被回收
 */
let tray = null;
function initTrayIcon() {
    const tempWindow = mainWindow;

    //这里定义图片的时候要区分windows和mac,mac的docker栏图标尺寸只要16x16,超过这个就太大了
    if (process.platform === 'darwin') {
        tray = new Tray(__static + '/image/tool16.png');
    }
    else{
        tray = new Tray(__static + '/image/tool.png');
    }

    //图标上面按右键弹出的按钮,一个是打开主页面,一个是退出程序
    const trayContextMenu = Menu.buildFromTemplate([
        {
            label: '打开',
            click: () => {
                mainWindow.show()
            }
        }, {
            label: '退出',
            click: () => {
                /**
                 * 主程序里关闭按钮点击的时候需要处理
                 * mainWindow.on('close', (e) => {
                        if (!canQuit) {
                            e.preventDefault();
                            mainWindow.hide();
                        }
                    })
                    如果是主程序点击关闭按钮,这时候是操作页面隐藏,通知区域图标存在
                    如果是通知区域图标上点击退出,才是真正的退出程序
                 */
                canQuit = true;
                app.quit();
            }
        }
    ]);
    tray.setToolTip('xxxxx管理系统');
    tray.on('click', () => {
        tempWindow.show();
    });
    tray.on('right-click', () => {
        //右键弹出按钮
        tray.popUpContextMenu(trayContextMenu);
    });
}

渲染进程

  渲染进程和普通的Vue工程一模一样,也是通过main.js, App.vue等作为入口,这个就不多做介绍。

// main.js
import Vue from 'vue'

import App from './App'
import router from './router'
import Antd from 'ant-design-vue';
import axios from "axios";
import 'ant-design-vue/dist/antd.css';
if (!process.env.IS_WEB) { // noinspection JSCheckFunctionSignatures
  Vue.use(require('vue-electron'))
}
Vue.config.productionTip = false
Vue.use(Antd);
const { ipcRenderer } = require("electron");
const {PosPrinter} = require('electron').remote.require("electron-pos-printer");
Vue.prototype.$ipcRenderer = ipcRenderer;
Vue.prototype.$printer = PosPrinter;
Vue.prototype.$axios = axios;
import common from "./common";
const initInfo= common.getInitInfo(ipcRenderer);
Vue.prototype.$configInfo = initInfo.configInfo;
Vue.prototype.$systemInfo = initInfo.systemInfo;
Vue.prototype.$baseUrl = initInfo.configInfo.baseUrl
import './assets/iconfont/iconfont.css'
import db from "./util/datasoure"
// noinspection JSUnusedGlobalSymbols
Vue.prototype.$db = db;
import Clipboard from 'vue-clipboard2'
Vue.use(Clipboard)
import colorPicker from "vcolorpicker"
Vue.use(colorPicker)
// noinspection JSUnusedGlobalSymbols
Vue.prototype.$common = common;

process.env['ELECTRON_DISABLE_SECURITY_WARNINGS'] = 'true'
import 'viewerjs/dist/viewer.css'
/* eslint-disable no-new */
const vue = new Vue({
  components: { App },
  router,
  template: '<App/>'
}).$mount('#app')
export default vue

主子进程之间通信

  主子进程之间的通信是这套框架一个比较重要的核心,Html页面是没有权限操作本地操作系统相关内容的,所以类似打印、剪切板等功能,都是通过渲染进程向主进程发起通信请求,主进程完成任务以后,再回复渲染进程结果。目前

主进程事件注册

//index.js
import {ipcMain} from 'electron'
const { clipboard } = require('electron')

//同步模式回调
ipcMain.on("customEventSync", (evt,data)=>{
    /**
     * data内容就是渲染进程发送过来的数据
     * 主进程操作相关业务逻辑
     * 比如 clipboard.writeText("写入剪切板信息")
     */
    evt.returnValue = Object; //向渲染进程传递的消息, 同步模式
})

//异步模式回调
ipcMain.on("customEventAsync", (evt,data)=>{
    /**
     * data内容就是渲染进程发送过来的数据
     * 主进程操作相关业务逻辑
     * 比如 clipboard.writeText("写入剪切板信息")
     */
    event.sender.send('callBack', Object);; //向渲染进程传递的消息, 异步模式
})

渲染进程发送事件

//main.js中全局注册$ipcRenderer,后续渲染进程页面可以直接使用
const { ipcRenderer } = require("electron");
Vue.prototype.$ipcRenderer = ipcRenderer;
<template>
  <div>xxxxx</div>
</template>
<script>
export default {
  name: "GlobalSearch",
  data(){
    return{
      
    }
  },mounted(){
    //处理异步回调的信息
    this.$ipcRenderer.on('callBack', (event, arg)=> { 
        console.log("这是来着异步回调的信息"); // prints "pong" 
    });    
  },methods:{
    copyAsync(record){
      //异步通信,回调信息通过mounted定义的异步事件接受
      this.$ipcRenderer.send("customEventAsync",record);
    },
    copySync(record){
      //同步通信,等待结果返回,直接获取
      const result = this.$ipcRenderer.sendSync("customEventSync",record);
    }
    
  }
}
</script>

客户端认证

  也是为了客户端安全,每个客户端使用之前,需要先从服务端分配一个RSA的公钥信息,每个公钥信息都是和客户端Mac地址绑定的,后续所有的请求都会进行数据加密,而且加密就是通过分配的RSA公钥进行加密。

/**
 * 获取当前客户端mac地址
 * 这里其实有点小问题,就是如果你是多网卡的,比如装了虚拟机软件什么的,可能会出现mac地址和上次不一样的情况
 */
const getMac = require('getmac').default

function ValidateClient(){
    //先做处理,如果公钥和用户有问题,则跳转到输入公钥和用户信息界面,成功了就继续,失败则不处理
    if (this.$configInfo.publicKey === "" || this.$configInfo.user === "") {
      //跳出输入公钥和用户唯一标签页面
      this.$refs.ShowLoadingEncrypt.show()
      return;
    }
    const that = this;
    //如果本地已有相关信息,则去服务端验证下,是否有效
    //用户信息后台自动控制,不需要人为传递
    //传递的mac信息是通过公钥加密后的数据,用于服务端验证
    activeClient(this.$systemInfo.mac).then(() => {
      if (this.product === "development") {
        this.activeKey = this.defaultActiveKey;
      }
    }).catch(() => {
      //如果验证失败,也是跳到页面,重新输入公钥和用户信息
      that.$nextTick(() => {
        that.$refs.ShowLoadingEncrypt.show()
      })
    });
}
/**
  后台验证RSA,MAC地址逻辑
 */
@PostMapping(value = "active", produces = "application/json")
public ResponseBodyVo active(@RequestBody MacRequestInfo macRequestInfo) {
    String result = rsaService.activeInfo(macRequestInfo);
    if(!StringUtils.isEmpty(result)){
        return ResponseBodyVo.activeFailure(result);
    }
    return ResponseBodyVo.success("");
}

public String activeInfo(MacRequestInfo macRequestInfo) {
    //看分配的唯一标签是不是在后台有记录
    RsaEntity rsaEntityRequest = new RsaEntity();
    rsaEntityRequest.setUser(macRequestInfo.getUser());
    RsaEntity rsaEntity = rsaDao.getRsaEntity(rsaEntityRequest);
    if (rsaEntity == null) {
        return ("找不到有效的加密信息!");
    }
    String privateKey = rsaEntity.getPrivateKey();
    //客户端传过来的mac信息,就是通过客户端公钥加密的,这里要解密一下,看加密数据是不是有效的
    String content = macRequestInfo.getContent();
    content =  content.replace("\"","");
    if (StringUtils.isEmpty(rsaEntity.getMac())) {
        rsaEntity.setMac(content);
        rsaDao.updateMac(rsaEntity);
    } else {
        if (!StringUtils.equalsIgnoreCase(content, rsaEntity.getMac())) {
            return ("当前私钥已绑定MAC地址" + rsaEntity.getMac() + ",请重新检查!");
        }
    }
    return "";
}

如果客户端参数不合法,就不允许打开

数据接口标准

  前后端统一数据格式,后台通过ResponseBodyVo统一返回格式,前端通过Axios的响应拦截器处理。

//后端统一
public class ResponseBodyVo {
    //错误代码
    private int code;
    //错误信息
    private String msg;
    //成功返回实体
    private Object data;
    //数据是否被加密
    private Boolean encrypt = false;
    //数量
    private int count;

    public int getCount() {
        return count;
    }

    public void setCount(int count) {
        this.count = count;
    }

    @JsonIgnore
    private RsaRequestEntity rsaRequestEntity;

    public ResponseBodyVo(int code, String msg, Object data){
        this.code = code;
        this.msg = msg;
        this.data =data;
    }

    public ResponseBodyVo(int code, String msg, Object data, boolean encrypt, RsaRequestEntity rsaRequestEntity){
        this.code = code;
        this.msg = msg;
        this.data = data;
        this.encrypt = encrypt;
        this.rsaRequestEntity = rsaRequestEntity;
    }
    public static ResponseBodyVo success(Object data){
        return new ResponseBodyVo(200, "", data);
    }
    public static ResponseBodyVo successDownload(Object data){
        return new ResponseBodyVo(205, "", data);
    }

    public static ResponseBodyVo activeFailure(Object data){
        return new ResponseBodyVo(208, "", data);
    }

    public static ResponseBodyVo success(Object data, boolean encrypt, RsaRequestEntity rsaRequestEntity){
        return new ResponseBodyVo(200, "", data, encrypt, rsaRequestEntity);
    }
    public static ResponseBodyVo failure(String msg){
        return new ResponseBodyVo(500, msg, null);
    }
    public static ResponseBodyVo failureAuth(String msg){
        return new ResponseBodyVo(401, msg, null);
    }

    public Boolean getEncrypt() {
        return encrypt;
    }

    public void setEncrypt(Boolean encrypt) {
        this.encrypt = encrypt;
    }

    public int getCode() {
        return code;
    }

    public void setCode(int code) {
        this.code = code;
    }

    public String getMsg() {
        return msg;
    }

    public void setMsg(String msg) {
        this.msg = msg;
    }

    public Object getData() {
        return data;
    }

    public void setData(Object data) {
        this.data = data;
    }

    public RsaRequestEntity getRsaRequestEntity() {
        return rsaRequestEntity;
    }

    public void setRsaRequestEntity(RsaRequestEntity rsaRequestEntity) {
        this.rsaRequestEntity = rsaRequestEntity;
    }
}

//统一返回数据格式
@PostMapping(value = "getDistDeleteList")
public ResponseBodyVo getDistDeleteList(@RequestBody RsaRequestEntity rsaRequestEntity){
    DistDeleteEntity distDeleteEntity = RequestParamUtil.getRequestParam(rsaRequestEntity,DistDeleteEntity.class);
    //数据自动加密处理
    return ResponseBodyVo.success(distDeleteService.getDistDeleteList(distDeleteEntity),true,rsaRequestEntity);
}

//前端拦截器处理
service.interceptors.response.use(res => {
        // 未设置状态码则默认成功状态
        const code = res.data.code || 200;
        // 208特殊处理,代表公私钥校验失败!
        if (code === 208) {
            return Promise.reject(new Error(res.data.data))
        }else if(code===205 || res.data.type==="application/octet-stream"){
            //文件流下载
            return res.data;
        } else if(code!==200){
            _this.$message.error(res.data.msg);
            return Promise.reject(new Error(res.data.data))
        }
        else {
            //数据进行解密
            if(!res.data.data){
                return res.data;
            }
            else if(res.data.data===''){
                return "";
            }
            else{
                return res.data.data;
            }
        }
    },
    error => {
        if (error.response) {
            _this.$message.error(error.response.data.msg)
            return Promise.reject(error.response.data)
        } else {
            console.log(error);
        }
    }
)

数据加密

  在上一步数据接口标准的基础上,统一对传输数据内容进行了加密解密。主要是通过JSEncrypt来实现。

yarn add jsencrypt

这里存在问题,jsencrypt原生不支持分段加密解密,也就是内容大的数据,加解密会失败,需要改一下源代码。

   我的处理方式,直接在node_modules下的jsencrypt文件夹复制到源代码工程内,然后改一下相关代码,调用的地方直接应用源代码中的内容。

路径:libs\jsencrypt\lib\JSEncrypt.js, 添加两段代码就行。

//分段解密
JSEncrypt.prototype.decryptUnicodeLong = function (string) {
    const k = this.getKey();
    const maxLength = ((k.n.bitLength()+7)>>3)*2;
    try {
        const hexString = b64tohex(string);
        let decryptedString = "";
        const rexStr=".{1," + maxLength + "}";
        const rex =new RegExp(rexStr, 'g');
        const subStrArray = hexString.match(rex);
        if(subStrArray){
            subStrArray.forEach(function (entry) {
                decryptedString += k.decrypt(entry);
            });
            return decryptedString;
        }
    } catch (ex) {
        return false;
    }
};
//分段加密
JSEncrypt.prototype.encryptUnicodeLong = function (string) {
    const k = this.getKey();
    //根据key所能编码的最大长度来定分段长度。key size - 11:11字节随机padding使每次加密结果都不同。
    const maxLength = ((k.n.bitLength()+7)>>3)-11;
    try {
        let subStr="", encryptedString = "";
        let subStart = 0, subEnd=0;
        let bitLen=0, tmpPoint=0;
        for(let i = 0, len = string.length; i < len; i++){
            //js 是使用 Unicode 编码的,每个字符所占用的字节数不同
            const charCode = string.charCodeAt(i);
            if(charCode <= 0x007f) {
                bitLen += 1;
            }else if(charCode <= 0x07ff){
                bitLen += 2;
            }else if(charCode <= 0xffff){
                bitLen += 3;
            }else{
                bitLen += 4;
            }
            //字节数到达上限,获取子字符串加密并追加到总字符串后。更新下一个字符串起始位置及字节计算。
            if(bitLen>maxLength){
                subStr=string.substring(subStart,subEnd)
                encryptedString += k.encrypt(subStr);
                subStart=subEnd;
                bitLen=bitLen-tmpPoint;
            }else{
                subEnd=i;
                tmpPoint=bitLen;
            }
        }
        subStr=string.substring(subStart,len)
        encryptedString += k.encrypt(subStr);
        return hex2b64(encryptedString);
    } catch (ex) {
        return false;
    }
};

前端统一加密

//在axios请求拦截器里统一处理
import { JSEncrypt } from '../libs/jsencrypt/lib/JSEncrypt'
// request拦截器
service.interceptors.request.use(config => {
    //........
    const encryptStr = new JSEncrypt({});
    encryptStr.setPublicKey(_this.$configInfo.publicKey);
    let data = config.data;
    if(typeof(data)!=="string"){
        data = JSON.stringify(data);
    }
    try{
        const enString =  encryptStr.encryptUnicodeLong(data);
        //所有返回后端的格式都是一样的,user就是唯一标签,content就是加密后的实体信息
        config.data = {
            user: _this.$configInfo.user,
            content: enString
        }
        return config
    }
    catch (e){
        _this.$configInfo.publicKey="";
        _this.$configInfo.user="";
    }
    //.......
    return config;

}, error => {
    Promise.reject(error)
})

前端统一解密

import { JSEncrypt } from '../libs/jsencrypt/lib/JSEncrypt'
// 响应拦截器
service.interceptors.response.use(res => {
        //...... 代码略,常规判断,获取到返回的加密数据
        const tempDecrypt = new JSEncrypt();
        tempDecrypt.setPublicKey(_this.$configInfo.publicKey);
        let result = tempDecrypt.decryptUnicodeLong(res.data.data);
        //解决中文乱码问题,后台都会encode下,这里decode解析下
        result = decodeURIComponent(result);
        return JSON.parse(result);
    },
    error => {
        if (error.response) {
            _this.$message.error(error.response.data.msg)
            return Promise.reject(error.response.data)
        } else {
            console.log(error);
        }
    }
)

后端统一加密

public static String encryptResponseData(RsaRequestEntity rsaRequestEntity, Object data){
    RsaDao rsaDao = SpringContextHolder.getBean(RsaDao.class);
    RsaEntity rsaEntityQuery = new RsaEntity();
    rsaEntityQuery.setUser(rsaRequestEntity.getUser());
    assert rsaDao != null;
    RsaEntity rsaEntity = rsaDao.getRsaEntity(rsaEntityQuery);
    if(rsaEntity==null){
        throw new GlobalException("数据加密失败!!");
    }
    try {
        String result = URLEncoder.encode(objectMapper.writeValueAsString(data),"UTF-8");
        //这里要处理下+号
        result = result.replace("+","%20");
        return RsaUtils.encryptByPrivateKey(rsaEntity.getPrivateKey(), result);
    } catch (JsonProcessingException | UnsupportedEncodingException e) {
        throw new GlobalException("数据解析失败!!" + e.getMessage());
    }
}

后端统一解密

/**
    * 按条件查询文件回收站信息
    * @param rsaRequestEntity rsa加密实体
*/
@PostMapping(value = "getDistDeleteList")
public ResponseBodyVo getDistDeleteList(@RequestBody RsaRequestEntity rsaRequestEntity){
    //数据解密过程
    DistDeleteEntity distDeleteEntity = RequestParamUtil.getRequestParam(rsaRequestEntity,DistDeleteEntity.class);
    return ResponseBodyVo.success(distDeleteService.getDistDeleteList(distDeleteEntity),true,rsaRequestEntity);
}

//单个实体
public static <T> T getRequestParam(RsaRequestEntity rsaRequestEntity, Class<?> cls){
    try {
        init();
        String content = decryptString(rsaRequestEntity);
        return (T)objectMapper.readValue(content, cls);
    } catch (JsonProcessingException e) {
        throw new GlobalException("数据解密失败!请联系系统管理员!" + e.getMessage());
    }
}
//list+实体
public static <T> List<T> getRequestParamList(RsaRequestEntity rsaRequestEntity, Class<?> cls){
    try {
        init();
        String content = decryptString(rsaRequestEntity);
        CollectionType listType  = objectMapper.getTypeFactory().constructCollectionType(ArrayList.class, cls);
        return objectMapper.readValue(content, listType);
    } catch (JsonProcessingException e) {
        throw new GlobalException("数据解密失败!请联系系统管理员!" + e.getMessage());
    }
}

public static String decryptString(RsaRequestEntity rsaRequestEntity){
    RsaEntity rsaEntityRequest = new RsaEntity();
    rsaEntityRequest.setUser(rsaRequestEntity.getUser());
    RsaDao rsaDao = SpringContextHolder.getBean(RsaDao.class);
    assert rsaDao != null;
    RsaEntity rsaEntity = rsaDao.getRsaEntity(rsaEntityRequest);
    if(rsaEntity==null){
        throw new GlobalException("找不到有效的加密信息!");
    }
    String privateKey = rsaEntity.getPrivateKey();
    return  RsaUtils.decryptByPrivateKey(privateKey, rsaRequestEntity.getContent());
}

客户端日志管理

//日志util,供外部调用的。
import logger from 'electron-log'
import {app} from 'electron'
Object.assign(console, logger.functions);
logger.transports.file.level = 'debug'
logger.transports.file.maxSize = 1002430 // 10M
logger.transports.file.format = '[{y}-{m}-{d} {h}:{i}:{s}.{ms}] [{level}]{scope} {text}'
let date = new Date()
date = date.getFullYear() + '-' + (date.getMonth() + 1) + '-' + date.getDate()
//日志存储地址
logger.transports.file.file = app.getPath('userData') + '\\electron_log\\app\\' + date + '.log'
logger.info(logger.transports.file.getFile())

//封装几个日志记录方法
const info =(param)=>{
    logger.info(param)
}
const error =(param)=> {
    logger.error(param)
}
const warn =(param)=> {
    logger.warn(param)
}
export  {
    info,
    error,
    warn
}

快捷方式注册

  快捷键是我日常比较常用的,这个工具我定义了几个核心的快捷键,比如打开调试页面,重新加载工程等,这个在发布运行的时候很有用,有时候碰到问题了,可以快捷键打开调试页面,看中间出了什么问题。

globalShortcut.register("ctrl+shift+a", function () {
    //自定义动作
})
//撤销快捷键
globalShortcut.unregister("ctrl+shift+a");
//撤销所有的快捷键
globalShortcut.unregisterAll("ctrl+shift+a");

快捷键支持修改

打包发布

#核心都在build.js里面,里面定义了主进程,渲染进程,WEB相关等各类打包参数
node .electron-vue/build.js && electron-builder

定义打包信息package.json

"build": {
    "productName": "xxxxxxxx",
    "appId": "com.xxxxxx",
    "directories": {
      "output": "build"
    },
    "asar": true,
    "asarUnpack": [
      "./node_modules/node-notifier/vendor/**"
    ],
    "extraResources": [
      {
        "from": "static/ColorPicker",
        "to": "./ColorPicker"
      }
    ],
    "files": [
      "dist/electron/**/*"
    ],
    "dmg": {
      "contents": [
        {
          "x": 410,
          "y": 150,
          "type": "link",
          "path": "/Applications"
        },
        {
          "x": 130,
          "y": 150,
          "type": "file"
        }
      ]
    },
    "mac": {
      "icon": "build/icons/setting.icns"
    },
    "win": {
      "icon": "build/icons/favicon.ico"
    },
    "linux": {
      "icon": "build/icons"
    }
  },

builder.js

process.env.NODE_ENV = 'production'
const { say } = require('cfonts')
const chalk = require('chalk')
const del = require('del')
// noinspection JSUnusedLocalSymbols
const { spawn } = require('child_process')
const webpack = require('webpack')
const Listr = require('listr')

//主进程配置信息
const mainConfig = require('./webpack.main.config')
//渲染进程配置信息
const rendererConfig = require('./webpack.renderer.config')
//web相关配置信息
const webConfig = require('./webpack.web.config')

const doneLog = chalk.bgGreen.white(' DONE ') + ' '
const errorLog = chalk.bgRed.white(' ERROR ') + ' '
const okayLog = chalk.bgBlue.white(' OKAY ') + ' '
const isCI = process.env.CI || false

const Multispinner = require('multispinner')

if (process.env.BUILD_TARGET === 'clean') clean()
else if (process.env.BUILD_TARGET === 'web') web()
else build()

function clean () {
  del.sync(['build/*', '!build/icons', '!build/icons/icon.*'])
  console.log(`\n${doneLog}\n`)
  process.exit()
}

async function build () {
  greeting()
  del.sync(['dist/electron/*', '!.gitkeep'])
  const tasksMain = ['main', 'renderer']
  new Multispinner(tasksMain, {
    preText: 'building',
    postText: 'process'
  })
  let results = ''
  const tasks = new Listr(
    [
      {
        title: 'building master process',
        task: async () => {
          await pack(mainConfig)
            .then(result => {
              results += result + '\n\n'
            })
            .catch(err => {
              console.log(`\n  ${errorLog}failed to build main process`)
              console.error(`\n${err}\n`)
            })
        }
      },
      {
        title: 'building renderer process',
        task: async () => {
          await pack(rendererConfig)
            .then(result => {
              results += result + '\n\n'
            })
            .catch(err => {
              console.log(`\n  ${errorLog}failed to build renderer process`)
              console.error(`\n${err}\n`)
            })
        }
      }
    ],
    { concurrent: 2 }
  )
  await tasks
    .run()
    .then(() => {
      process.stdout.write('\x1B[2J\x1B[0f')
      console.log(`\n\n${results}`)
      console.log(`${okayLog}take it away ${chalk.yellow('`electron-builder`')}\n`)
      process.exit()
    })
    .catch(() => {
      process.exit(1)
    })
}

function pack (config) {
  return new Promise((resolve, reject) => {
    config.mode = 'production'
    webpack(config, (err, stats) => {
      if (err) reject(err.stack || err)
      else if (stats.hasErrors()) {
        let err = ''
        stats.toString({
          chunks: false,
          colors: true
        })
        .split(/\r?\n/)
        .forEach(line => {
          err += `    ${line}\n`
        })
        reject(err)
      } else {
        resolve(stats.toString({
          chunks: false,
          colors: true
        }))
      }
    })
  })
}

function web () {
  del.sync(['dist/web/*', '!.gitkeep'])
  webConfig.mode = 'production'
  webpack(webConfig, (err, stats) => {
    if (err || stats.hasErrors()) console.log(err)
    console.log(stats.toString({
      chunks: false,
      colors: true
    }))
    process.exit()
  })
}

function greeting () {
  const cols = process.stdout.columns
  let text
  if (cols > 85) text = 'lets-build'
  else if (cols > 60) text = 'lets-|build'
  else text = false
  if (text && !isCI) {
    say(text, {
      colors: ['yellow'],
      font: 'simple3d',
      space: false
    })
  } else console.log(chalk.yellow.bold('\n  lets-build'))
  console.log()
}

主进程配置信息 webpack.main.config.js

process.env.BABEL_ENV = 'main'
const path = require('path')
const { dependencies } = require('../package.json')
const webpack = require('webpack')
const MinifyPlugin = require("babel-minify-webpack-plugin")
let mainConfig = {
  entry: {
    main: path.join(__dirname, '../src/main/index.js')
  },
  externals: [
    ...Object.keys(dependencies || {})
  ],
  devtool:'inline-source-map',
  module: {
    rules: [
      {
        test: /\.js$/,
        use: 'babel-loader',
        exclude: /node_modules/
      },
      {
        test: /\.node$/,
        use: 'node-loader'
      }
    ]
  },
  node: {
    __dirname: process.env.NODE_ENV !== 'production',
    __filename: process.env.NODE_ENV !== 'production'
  },
  output: {
    filename: '[name].js',
    libraryTarget: 'commonjs2',
    path: path.join(__dirname, '../dist/electron')
  },
  plugins: [
    new webpack.NoEmitOnErrorsPlugin()
  ],
  resolve: {
    extensions: ['.js', '.json', '.node']
  },
  target: 'electron-main'
}

if (process.env.NODE_ENV !== 'production') {
  mainConfig.plugins.push(
    new webpack.DefinePlugin({
      '__static': `"${path.join(__dirname, '../static').replace(/\\/g, '\\\\')}"`
    })
  )
}
if (process.env.NODE_ENV === 'production') {
  mainConfig.plugins.push(
    new MinifyPlugin(),
    new webpack.DefinePlugin({
      'process.env.NODE_ENV': '"production"'
    })
  )
}
module.exports = mainConfig

渲染进程配置信息 webpack.renderer.config.js

process.env.BABEL_ENV = 'renderer'
const path = require('path')
const { dependencies } = require('../package.json')
const webpack = require('webpack')
const MinifyPlugin = require("babel-minify-webpack-plugin")
const CopyWebpackPlugin = require('copy-webpack-plugin')
const MiniCssExtractPlugin = require('mini-css-extract-plugin')
const HtmlWebpackPlugin = require('html-webpack-plugin')
const { VueLoaderPlugin } = require('vue-loader')
let whiteListedModules = ['vue','ant-design-vue']
let rendererConfig = {
  devtool: '#cheap-module-eval-source-map',
  entry: {
    renderer: path.join(__dirname, '../src/renderer/main.js')
  },
  externals: [
    ...Object.keys(dependencies || {}).filter(d => !whiteListedModules.includes(d))
  ],
  module: {
    rules: [
      {
        test: /\.less$/,
        use: ['vue-style-loader', 'css-loader', 'less-loader']
      },
      {
        test: /\.css$/,
        use: ['vue-style-loader', 'css-loader']
      },
      {
        test: /\.html$/,
        use: 'vue-html-loader'
      },
      {
        test: /\.js$/,
        use: 'babel-loader',
        exclude: /node_modules/
      },
      {
        test: /\.node$/,
        use: 'node-loader'
      },
      {
        test: /\.vue$/,
        use: {
          loader: 'vue-loader',
          options: {
            extractCSS: process.env.NODE_ENV === 'production',
            loaders: {
              sass: 'vue-style-loader!css-loader!sass-loader?indentedSyntax=1',
              scss: 'vue-style-loader!css-loader!sass-loader',
              less: 'vue-style-loader!css-loader!less-loader'
            }
          }
        }
      },
      {
        test: /\.(png|jpe?g|gif|svg)(\?.*)?$/,
        use: {
          loader: 'url-loader',
          query: {
            limit: 10000,
            name: 'imgs/[name]--[folder].[ext]'
          }
        }
      },
      {
        test: /\.(mp4|webm|ogg|mp3|wav|flac|aac)(\?.*)?$/,
        loader: 'url-loader',
        options: {
          limit: 10000,
          name: 'media/[name]--[folder].[ext]'
        }
      },
      {
        test: /\.(woff2?|eot|ttf|otf)(\?.*)?$/,
        use: {
          loader: 'url-loader',
          query: {
            limit: 10000,
            name: 'fonts/[name]--[folder].[ext]'
          }
        }
      }
    ]
  },
  node: {
    __dirname: process.env.NODE_ENV !== 'production',
    __filename: process.env.NODE_ENV !== 'production'
  },
  plugins: [
    new VueLoaderPlugin(),
    new MiniCssExtractPlugin({filename: 'styles.css'}),
    new HtmlWebpackPlugin({
      filename: 'index.html',
      template: path.resolve(__dirname, '../src/index.ejs'),
      templateParameters(compilation, assets, options) {
        return {
          compilation: compilation,
          webpack: compilation.getStats().toJson(),
          webpackConfig: compilation.options,
          htmlWebpackPlugin: {
            files: assets,
            options: options,
          },
          process,
        };
      },
      minify: {
        collapseWhitespace: true,
        removeAttributeQuotes: true,
        removeComments: true
      },
      nodeModules: process.env.NODE_ENV !== 'production'
        ? path.resolve(__dirname, '../node_modules')
        : false
    }),
    new webpack.NoEmitOnErrorsPlugin()
  ],
  output: {
    filename: '[name].js',
    libraryTarget: 'commonjs2',
    path: path.join(__dirname, '../dist/electron')
  },
  resolve: {
    alias: {
      '@': path.join(__dirname, '../src/renderer'),
      'vue$': 'vue/dist/vue.esm.js'
    },
    extensions: ['.js', '.vue', '.json', '.css', '.node']
  },
  target: 'electron-renderer'
}
if (process.env.NODE_ENV !== 'production') {
  rendererConfig.plugins.push(
    new webpack.HotModuleReplacementPlugin(),
    new webpack.DefinePlugin({
      '__static': `"${path.join(__dirname, '../static').replace(/\\/g, '\\\\')}"`,
      'process.env.VUE_APP_SERVER_URL':'http://localhost:8188'
    })
  )
}
if (process.env.NODE_ENV === 'production') {
  rendererConfig.devtool = ''
  rendererConfig.plugins.push(
    new MinifyPlugin(),
    new CopyWebpackPlugin([
      {
        from: path.join(__dirname, '../static'),
        to: path.join(__dirname, '../dist/electron/static'),
        ignore: ['.*']
      }
    ]),
    new webpack.DefinePlugin({
      'process.env.NODE_ENV': '"production"',
      'process.env.VUE_APP_SERVER_URL':'https://'
    }),
    new webpack.LoaderOptionsPlugin({
      minimize: true
    })
  )
}
module.exports = rendererConfig

web相关配置信息 webpack.web.config.js

process.env.BABEL_ENV = 'web'
const path = require('path')
const webpack = require('webpack')
const MinifyPlugin = require("babel-minify-webpack-plugin")
const CopyWebpackPlugin = require('copy-webpack-plugin')
const MiniCssExtractPlugin = require('mini-css-extract-plugin')
const HtmlWebpackPlugin = require('html-webpack-plugin')
const { VueLoaderPlugin } = require('vue-loader')
let webConfig = {
  devtool: '#cheap-module-eval-source-map',
  entry: {
    web: path.join(__dirname, '../src/renderer/main.js')
  },
  module: {
    rules: [
      {
        test: /\.less$/,
        use: ['vue-style-loader', 'css-loader', 'less-loader']
      },
      {
        test: /\.css$/,
        use: ['vue-style-loader', 'css-loader']
      },
      {
        test: /\.html$/,
        use: 'vue-html-loader'
      },
      {
        test: /\.js$/,
        use: 'babel-loader',
        include: [ path.resolve(__dirname, '../src/renderer') ],
        exclude: /node_modules/
      },
      {
        test: /\.vue$/,
        use: {
          loader: 'vue-loader',
          options: {
            extractCSS: true,
            loaders: {
              sass: 'vue-style-loader!css-loader!sass-loader?indentedSyntax=1',
              scss: 'vue-style-loader!css-loader!sass-loader',
              less: 'vue-style-loader!css-loader!less-loader'
            }
          }
        }
      },
      {
        test: /\.(png|jpe?g|gif|svg)(\?.*)?$/,
        use: {
          loader: 'url-loader',
          query: {
            limit: 10000,
            name: 'imgs/[name].[ext]'
          }
        }
      },
      {
        test: /\.(woff2?|eot|ttf|otf)(\?.*)?$/,
        use: {
          loader: 'url-loader',
          query: {
            limit: 10000,
            name: 'fonts/[name].[ext]'
          }
        }
      }
    ]
  },
  plugins: [
    new VueLoaderPlugin(),
    new MiniCssExtractPlugin({filename: 'styles.css'}),
    new HtmlWebpackPlugin({
      filename: 'index.html',
      template: path.resolve(__dirname, '../src/index.ejs'),
      templateParameters(compilation, assets, options) {
        return {
          compilation: compilation,
          webpack: compilation.getStats().toJson(),
          webpackConfig: compilation.options,
          htmlWebpackPlugin: {
            files: assets,
            options: options,
          },
          process,
        };
      },
      minify: {
        collapseWhitespace: true,
        removeAttributeQuotes: true,
        removeComments: true
      },
      nodeModules: false
    }),
    new webpack.DefinePlugin({
      'process.env.IS_WEB': 'true'
    }),
    new webpack.HotModuleReplacementPlugin(),
    new webpack.NoEmitOnErrorsPlugin()
  ],
  output: {
    filename: '[name].js',
    path: path.join(__dirname, '../dist/web')
  },
  resolve: {
    alias: {
      '@': path.join(__dirname, '../src/renderer'),
      'vue$': 'vue/dist/vue.esm.js'
    },
    extensions: ['.js', '.vue', '.json', '.css']
  },
  target: 'web'
}
if (process.env.NODE_ENV === 'production') {
  webConfig.devtool = ''
  webConfig.plugins.push(
    new MinifyPlugin(),
    new CopyWebpackPlugin([
      {
        from: path.join(__dirname, '../static'),
        to: path.join(__dirname, '../dist/web/static'),
        ignore: ['.*']
      }
    ]),
    new webpack.DefinePlugin({
      'process.env.NODE_ENV': '"production"'
    }),
    new webpack.LoaderOptionsPlugin({
      minimize: true
    })
  )
}
module.exports = webConfig

总结

  技术为业务服务,整体框架搭建好以后,就可以逐步实现各项功能了。