[实战] 使用Electron + Vue3 + Ts 实现定时提醒休息软件

使用Electron + Vue3 + Ts 实现定时提醒休息软件

前言

对于一直面对电脑的程序员,眼睛的休息是很重要的。可是咱们程序员又太过于勤勤恳恳、聚精会神、专心致志、不辞辛苦!不免会忽略了时间的流逝。javascript

因此咱们迫切的须要一个定时提醒软件,来帮助咱们管理时间!css

秉承着钻研技术的理念,此次咱们就本身来动手作一个定时提醒软件。html

本文将会从项目搭建 -> 代码实现 -> 应用打包,手把手一行行代码的带你完成这个项目。vue

看完本文你将学会什么知识呢?java

  1. electron:基本使用、进程通讯、打包
  2. vue3: composition API、路由、vite
  3. node: 多进程相关知识

让咱们开始吧~!node

原文地址:lei4519.github.io/blog/practi…ios

项目搭建

Vue3搭建(渲染进程代码)

首先搭建一个vue3的项目,咱们将使用随着vue3的到来一样大火的vite来搭建。git

$ yarn create vite-app remind-rest
$ cd remind-rest
$ yarn
$ yarn dev
复制代码

执行完上面的命令,打开http://localhost:3000/就能够看到启动的vue项目了。程序员

接入electron(主进程代码)

接下来咱们将vue项目放入electron中运行es6

首先安装electron + typescript(注意设置淘宝源或者使用cnpm下载)

$ yarn add dev electron typescript
复制代码

使用npx tsc --init初始化咱们的tsconfig.json,vue中的ts文件会被vite进行处理,因此这里的tsconfig配置只处理咱们的electron文件便可,咱们增长include属性include: ["main/"]

咱们会把打包后的代码都放到dist目录中,因此配置一下outDir属性,将ts编译后的文件放入dist/main目录中

修改以下

{
  "compilerOptions": {
    "outDir": "./dist/main",
  },
  "include": ["main/"]
}
复制代码

在根目录建立main文件夹,用来存放electron主进程中的代码

在main目录中新建index.ts

const {app, BrowserWindow} = require('electron')
// Electron会在初始化完成而且准备好建立浏览器窗口时调用这个方法
app.whenReady().then(createWindow)

// 建立一个窗口
function createWindow() {
  const win = new BrowserWindow()
  win.loadURL('http://localhost:3000')
}
复制代码

嗯,so easy!加上注释换行才9行代码,启动一下试试看~

咱们在package.json中加一个脚本main-dev,而后执行

"scripts": {
  "dev": "vite",
  "build": "vite build",
  "main-dev": "electron ./main/index.ts"
}
复制代码

不出意外你应该已经能够看到启动的桌面应用了,而里面显示的正是咱们的vue项目。

至此,开发环境已经搭建完毕,接下来咱们梳理一下需求,看一下咱们要作的究竟有哪些功能。而后开始实现代码。

需求梳理

咱们要实现哪些页面?

设置页面

倒计时提示框

锁屏页面

咱们须要实现什么功能?

  1. 用户能够设置工做时间、休息时间、提示时间
  2. 系统托盘栏中显示工做时间倒计时,托盘栏菜单项:设置 暂停 继续 重置 退出
  3. 工做倒计时剩余时间等于提示时间,显示提示框,提醒用户还有几秒进入锁屏界面
  4. 用户能够点击提示框中的暂停重置按钮,对倒计时进行操做
  5. 倒计时结束,进入锁屏界面
  6. 进入锁屏界面后,屏幕上显示休息倒计时和关闭按钮。
  7. 用户只能经过点击关闭按钮提早退出锁屏界面,其余全部常规操做都没法退出锁屏界面(如切换屏幕、切换软件、cmd + Q)
  8. 休息倒计时结束,自动退出锁屏界面,从新开始工做时间倒计时

好了,需求梳理完毕,让咱们开始快乐的codeing吧👌~

代码实现

完善渲染进程目录

在vue项目中建立以下文件

- src
  - main.js // 入口文件
  - route.js // 路由配置
  - App.vue
  - views
    - LockPage.vue // 锁屏界面
    - Tips.vue // 提示气泡界面
    - Setting.vue // 设置界面
复制代码

安装vue-router

yarn add vue-router@^4.0.0-alpha.4
复制代码

其中 main.js route.js都是vue3的新写法,和老版本没有太大区别,就不详细说明了,直接看代码吧

views文件夹中的文件咱们后面再具体实现

main.js

import { createApp } from 'vue'
import App from './App.vue'
import router from './route'
const app = createApp(App)
app.use(router)
router.isReady().then(() => app.mount('#app'))
复制代码

route.js

import {createRouter, createWebHashHistory} from 'vue-router'
import LockPage from './views/LockPage.vue'
import Tips from './views/Tips.vue'
import Setting from './views/Setting.vue'
export default createRouter(
  {
    history: createWebHashHistory(),
    routes: [
      {
        path: '/LockPage',
        name: 'LockPage',
        component: LockPage
      },
      {
        path: '/Tips',
        name: 'Tips',
        component: Tips
      },
      {
        path: '/Setting',
        name: 'Setting',
        component: Setting
      }
    ]
  }
)
复制代码

App.vue

<template>
  <router-view></router-view>
</template>

<script>
export default {
  name: 'App'
}
</script>
复制代码

完善主进程目录

- main
  - index.ts // 入口
  - tary.ts // 托盘模块
  - browserWindow.ts // 建立渲染进程窗口
  - countDown.ts // 倒计时模块
  - setting.ts // 设置模块
  - utils.ts // 工具代码
  - store.json // 本地存储
复制代码

主进程自动重启

渲染进程的代码,每次咱们修改以后都会进行热更新。而主进程的代码却没有这样的功能(社区中未找到相关实现),这就致使在主进程的开发过程当中咱们须要频繁的手动重启终端以去查看效果,这显然是一件很不效率的事情。这里咱们经过node的api来简单实现一个主进程代码的自动重启的功能。

思路其实也很简单,就是监听到文件变动后,自动重启终端

首先咱们须要使用node来运行终端命令,这样才能去进行控制。node怎么运行终端命令呢?使用child_process中的spawn模块就能够了,不熟悉的同窗能够看一下这片文章child_process spawn模块详解

在根目录新建一个scripts文件夹,用来存放咱们的脚本文件

而后在scripts目录中建立createShell.js dev.js这两个文件

mkdir scripts
cd scripts
touch createShell.js dev.js
复制代码

createShell.js文件中,建立一个工厂函数,传入终端命令,返回执行此命令的终端实例,代码以下:

const { spawn } = require('child_process')

module.exports = function createShell(command) {
  return spawn(command, {
    shell: true
  })
}
复制代码

接下来咱们实现dev.js的内容,先来捋一下思路,当咱们执行dev.js的时候,咱们须要执行以下命令:

  1. 启动vite,运行渲染进程的代码

  2. 启动tsc,编译主进程的代码

  3. 等到tsc编译成功,启动electron

  4. 监听到electron进程发出的重启信号,重启electron

    `&&`表明串行命令,前一个执行完才会执行后一个
    
     `&`表明并行命令,先后两个命令同时执行
    复制代码
// 引入咱们刚才写的工厂函数
const createShell = require('./createShell')

// 运行vite 和 tsc
const runViteAndTsc = () => new Promise((reslove) => {
  // 运行终端命令 下面会解释
  createShell('npx vite & rm -rf ./dist/main && mkdir dist/main && cp -r main/store.json dist/main/store.json && tsc -w').stdout.on('data', buffer => {
    // 输出子进程信息到控制台
    console.log(buffer.toString())
    // tsc在每次编译生成后,会输出Watching for file changes
    // 这里利用Promise状态只会改变一次的特性,来保证后续的代码逻辑只执行一次
    if (buffer.toString().includes('Watching for file changes')) {
      reslove()
    }
  })
})
// 运行electron
const runElectron = () => {
  // 定义环境变量,启动electron
  createShell('cross-env NODE_ENV=development electron ./dist/main/index.js')
    //监听到子进程的退出事件
    .on('exit', (code) => {
      // 约定信号100为重启命令,从新执行终端
      if (code === 100) runElectron()
      // 使用kill而不是exit,否则会致使子进程没法所有退出
      if (code === 0) process.kill(0)
    })
}

// 串起流程,执行命令
runViteAndTsc()
  .then(runElectron)
复制代码

在这里解释一下上面的终端命令,咱们格式化一下

npx vite & rm -rf ./dist/main &&
mkdir dist/main &&
cp -r main/store.json dist/main/store.json &&
tsc -w

1. 运行vite,同时删除掉上一次编译产生的main目录
2. 删除目录后,从新建一个空的main目录
3. 重建的目的是为了这行的copy命令,ts不会编译非.ts文件,咱们须要手动拷贝store.json文件
4. 拷贝完成后,开始编译ts
复制代码

这里补充一下,本身来写启动命令除了实现自动刷新以外,还有下面的缘由:

  1. electron 也能够直接运行ts文件,可是并不会编译ts,不编译的话在ts文件中就没法使用import,不使用import就没办法得到代码自动导入和提示功能,因此要先使用tsc编译ts文件成为js,而后再使用electron运行js。
  2. 而直接在终端输入命令是没法实现上述流程的,由于咱们须要使用 tsc -w 功能来监听文件变化从新编译,这就致使 ts 编译完成后并不会退出,因此没法使用 && 串行命令执行electron,而使用 & 并行命令可能会出现electron运行时,ts文件可能尚未编译成功致使electron加载js文件不存在而启动失败的问题。因此咱们须要本身写命令来进行控制。

以上只完成了第一步,接下来咱们要监听文件变化并退出electron进程,退出时咱们传入code:100,来通知外部这是一次重启

先写一个辅助函数, 递归遍历指定目录下的全部文件,并执行传入的回调函数,向回调函数中传入当前文件的路径

main/utils.ts

import fs from 'fs'

type callback = (name: string) => void
export function readDeepDir(path: string, fn: callback) {
  const _reader = (path: string) => {
    fs.readdirSync(path).forEach((name) => {
      if (!fs.statSync(path + '/' + name).isDirectory()) {
        return fn(path + '/' + name)
      }
      return _reader(path + '/' + name)
    })
  }
  return _reader(path)
}
复制代码

main/index.ts中监听当前的主进程目录,只要有文件变化,咱们就执行app.exit(100)退出当前进程

import { readDeepDir } from './utils'

function watchFile() {
  let debounce: NodeJS.Timeout
  const reload = () => {
    clearTimeout(debounce)
    debounce = setTimeout(() => {
      // 当前应用退出,外部进程接收到以后重启应用
      app.exit(100)
    }, 300)
  }
  // fs.watch并不稳定,使用watchFile进行监听
  const watcher = (path: string) => {
    fs.watchFile(path, reload)
  }
  readDeepDir(__dirname, watcher)
}
复制代码

说明一下

  1. 不使用fs.watch是由于这个api并不稳定,会致使刷新结果不符合预期。watchFile是监听不到新增文件的,这个解决方案实际上是借助tsc -w的能力,当有已监听的文件去引用新增文件的时候,就会致使tsc从新编译,而后触发自动刷新,当第二次启动electron的时候,就会把新的文件进行监听了

  2. electron是有app.relaunch()api的,调用这个api就会重启应用,那咱们为何不使用这个而要本身去写呢?是由于app.relaunch实际上是另起了一个进程来运行新的electron,当前这个进程咱们须要执行app.exit()来退出才能够,这是在官网说明的。可是若是咱们这么作的话,app.relaunch启动的这个进程就会脱离了咱们node scripts/dev.js这个进程的管控,致使咱们中断node scripts/dev.js这个进程的时候,app.relaunch启动的这个进程还在运行的问题。

到此自动刷新就完成了,让咱们真正的来实现代码逻辑吧!

主进程实现

main/index.ts

import { app } from 'electron'
import fs from 'fs'
import {inittary} from './tary'
export const isDev = process.env.NODE_ENV === 'development'
// 自动刷新
isDev && watchFile()

// 隐藏dock
app.dock.hide()

// Electron会在初始化完成而且准备好建立浏览器窗口时调用这个方法
app.whenReady().then(() => {
  inittary()
})
复制代码
  1. 首先咱们获取到当前的环境信息,若是是开发环境,就把刚才实现的自动刷新功能使用上。
  2. 隐藏dock栏,由于咱们的应用功能主要在托盘栏,不须要展现dock栏的图标
  3. 当咱们的app启动完成后,初始化托盘栏

index.js 代码很简单,这里的inittary咱们还没实现,在实现它以前,让咱们先把倒计时模块写好

main/countDown.ts

首先定义些关于时间的常量

export const BASE_UNIT = 60
export const SECONDS = 1000
export const MINUTES = SECONDS * BASE_UNIT
export const HOURS = MINUTES * BASE_UNIT
复制代码

将倒计时模块写成一个类,方便管理

这个类有三个私有属性

class CountDown {
  // 用来计算当前的时间
  private time = 0
  // 保存传入的时间,重置时会用到
  private _time = 0
  // 清除定时器,暂停时会用到
  private timer?: NodeJS.Timeout
}
复制代码

接下来实现相关方法,咱们须要有设置时间、暂停时间、重置时间,启动倒计时这几个功能

setTime(ms: number) {
  // 若是以前有一个定时器在运行,就中断掉
  this.stop()
  this.time = this._time = ms
  return this
}
stop() {
  this.timer && clearInterval(this.timer)
}
resetTime() {
  this.time = this._time
  return this
}
run() {
  this.timer = setInterval(() => {
    this.time -= SECONDS
  }, SECONDS)
}
复制代码

easy~ 再定义一个静态方法,用于将时间戳转换为咱们的须要的时间格式

static formatTimeByMs(ms: number) {
  return {
    h: String((ms / HOURS) | 0).padStart(2, '0'),
    m: String((ms / MINUTES) % BASE_UNIT | 0).padStart(2, '0'),
    s: String((ms / SECONDS) % BASE_UNIT | 0).padStart(2, '0'),
  }
}
复制代码

ok,大致功能写好了,接下来咱们须要把时间的变化发送出去

为了时间的精确性,再使用时咱们将为倒计时模块单独开一个进程,因此这里也使用进程通讯的方式来发送消息

先定义发送消息的接口

export interface SendMsg {
  // 格式化后的时间
  time: {
    h: string
    m: string
    s: string
  }
  // 原始时间戳
  ms: number
  // 时间是否归零
  done: boolean
}
复制代码

写一个发送消息的方法

private send(msg: SendMsg) {
  process.send!(msg)
}
复制代码

而后在重置时间和启动时间时给父进程发送消息

resetTime() {
  this.time = this._time
  this.send({
    time: CountDown.formatTimeByMs(this.time),
    ms: this.time,
    done: this.time <= 0
  })
  return this
}
run() {
  this.send({
    time: CountDown.formatTimeByMs(this.time),
    ms: this.time,
    done: this.time <= 0
  })
  this.timer = setInterval(() => {
    let done: boolean
    if (done = this.time <= 0) this.stop()
    this.send({
      time: CountDown.formatTimeByMs(this.time -= SECONDS),
      ms: this.time,
      done
    })
  }, SECONDS)
}
复制代码

OK,发送消息的逻辑咱们处理完成了,接下来处理一下接收消息的流程

首先定义接口,这会比较复杂,由于咱们的这些方法中,setTime是须要传入参数的,而其余的方法并不须要,若是想准确进行提示,那咱们就须要这么作

首先咱们将须要接收参数的方法名定义一个type,这里是将类型当成了变量来使用

type hasDataType = 'setTime'
复制代码

而后咱们定义不接受参数的接口,这里使用了两个技巧

  1. keyof:由于咱们的类中向外暴露的其实只有setTime、resetTime、run、stop,其余的都是私有变量或者静态方法,因此这里咱们使用keyof就能够把这四个方法名取出来供类型系统使用
  2. Exclude:咱们取出的名称中,setTime是须要传递参数的,因此使用Exclude将这个名称排除掉

这样操做以后,这里的type其实就是 resetTime | run | stop

interface ReceiveMsgNoData {
  type: Exclude<keyof CountDown, hasDataType>
}
复制代码

接收参数的接口就很简单了

interface ReceiveMsgHasData {
  type: hasDataType
  data: number
}
复制代码

最终定义一个联合类型供外部使用,这里之因此要定义数组类型,是为了方便外部使用,以后的代码中咱们能够看到用法了

export type ReceiveMsg = ReceiveMsgNoData | ReceiveMsgHasData | Array<ReceiveMsgNoData | ReceiveMsgHasData>
复制代码

接口定义完了,来实现一下代码

const c = new CountDown()
process.on('message', (message: ReceiveMsg) => {
  if (!Array.isArray(message)) {
    message = [message]
  }
  message.forEach((msg) => {
    if (msg.type === 'setTime') {
      c[msg.type](msg.data)
    } else {
      c[msg.type]()
    }
  })
})
复制代码

接收消息的功能也实现了,至此倒计时模块就写完了,快让咱们去tary.js中把它使用起来吧!~

main/tary.ts

一样的,tary也将使用类来实现

在代码实现以前,咱们先来捋一下逻辑

  • 实例化Tary时:设置菜单项 -> 监听倒计时模块消息 -> 开始倒计时
  • 监听倒计时时间变化
    1. 若是当前是工做时间的倒计时,设置托盘栏文字为当前时间
    2. 若是剩余时间等于提示时间,显示提示框,监听提示框进程的消息通讯
    3. 工做倒计时结束:关闭提示框进程。打开锁屏窗口,切换至休息时间倒计时
    4. 时间变化时传递给锁屏渲染进程,以供渲染进程渲染时间
    5. 锁屏进程点击关闭或者倒计时归零,通知主进程关闭锁屏界面,切换至工做时间倒计时

先定义要使用的私有属性

import { Tray as ElectronTary } from 'electron'

type TimeType = 'REST' | 'WORK'
class Tary {
  // 初始化托盘栏,并传入托盘图标
  private tray: ElectronTary = new ElectronTary(
    path.resolve(__dirname, '../icon/img.png')
  )
  // 标示当前时间为工做时间或休息时间
  private timeType: TimeType = 'WORK'
  // 菜单实例
  private menu: Menu | null = null
  // 锁屏窗口实例
  private restWindows: BrowserWindow[] | null = null
  // 提示框口实例
  private tipsWindow: BrowserWindow | null = null
  // 倒计时模块 使用 child_process.fork 建立一个子进程
  private countDown: ChildProcess = fork(path.resolve(__dirname, './countDown'))
}
复制代码

定义向子进程发送消息的方法

send(message: ReceiveMsg | ReceiveMsg[]) {
  this.countDown.send(message)
}
复制代码

设置菜单项,这里其实就是调用electron的api,详细的能够看官方文档。

当用户点击暂停、继续、重置时,给倒计时模块发送消息。偏好设置的功能咱们后面再实现

private setContextMenu() {
  this.menu = Menu.buildFromTemplate([
    {
      label: '偏好设置',
      accelerator: 'CmdOrCtrl+,',
      click: () => {},
    },
    {
      type: 'separator',
    },
    {
      id: 'play',
      label: '继续',
      accelerator: 'CmdOrCtrl+p',
      visible: false,
      click: (menuItem) => {
        this.send({
          type: 'run'
        })
        // 暂停和继续 只显示其中一个
        menuItem.menu.getMenuItemById('pause').visible = true
        menuItem.visible = false
      },
    },
    {
      id: 'pause',
      label: '暂停',
      accelerator: 'CmdOrCtrl+s',
      visible: true,
      click: (menuItem) => {
        this.send({
          type: 'stop'
        })
        // 暂停和继续 只显示其中一个
        menuItem.menu.getMenuItemById('play').visible = true
        menuItem.visible = false
      },
    },
    {
      label: '重置',
      accelerator: 'CmdOrCtrl+r',
      click: (menuItem) => {
        menuItem.menu.getMenuItemById('play').visible = false
        menuItem.menu.getMenuItemById('pause').visible = true
        this.startWorkTime()
      },
    },
    {
      type: 'separator',
    },
    { label: '退出', role: 'quit' },
  ])
  this.tray.setContextMenu(this.menu)
}
复制代码

监听倒计时模块消息

handleTimeChange() {
  this.countDown.on('message', (data: SendMsg) => {
    if (this.timeType === 'WORK') {
      this.handleWorkTimeChange(data)
    } else {
      this.handleRestTimeChange(data)
    }
  })
}
复制代码

开始工做时间倒计时

private startWorkTime() {
  this.send([
    {
      type: 'setTime',
      data: workTime,
    },
    {
      type: 'run',
    },
  ])
}
复制代码

实例化时调用上面的方法

constructor() {
  this.setContextMenu()
  this.handleTimeChange()
  this.startWorkTime()
}
复制代码

上面代码执行完成后,倒计时就启动了,接下来就要处理时间变化的逻辑了

先来处理工做时间的变化

handleWorkTimeChange({ time: {h, m, s}, ms, done }: SendMsg) {
  this.tary.setTitle(`${h}:${m}:${s}`) // 1
  if (ms <= tipsTime) {
    this.handleTipsTime(s, done) // 2
  } else if (this.tipsWindow) {
    this.closeTipsWindow() // 3
  }
  if (done) {
    this.toggleRest() // 4
  }
}
复制代码
  1. 首先咱们使用tary模块的setTitle api,将文字设置到托盘栏中。
  2. 接着咱们判断一下当前的时间是否是到了提示用户的时间,若是到了时间就开始展现提示框
  3. else if 的逻辑是一个容错处理,若是当前时间不是提示时间,可是提示框却存在的话,就关闭提示框。这种状况在重置时间的时候会发生。
  4. 若是工做时间结束了,就切换处处理休息时间的逻辑上。
展现提示框
export const TIPS_MESSAGE = 'TIPS_MESSAGE'

handleTipsTime(s: string, done: boolean) {
  if (!this.tipsWindow) { // 初始化
    ipcMain.on(TIPS_MESSAGE, this.handleTipsMsg)
    this.tipsWindow = createTipsWindow(this.tary.getBounds(), s)
  } else { // 发送消息
    this.tipsWindow.webContents.send(TIPS_MESSAGE, {
      s,
      done
    })
  }
}
复制代码
  1. 若是是以前没有提示气泡窗口,就作初始化的工做:监听渲染进程的消息,建立提示气泡窗口
  2. 若是已经有了窗口就向窗口中发送时间变化的消息。

监听提示框渲染进程的消息

interface TipsMsgData {
  type: 'CLOSE' | 'RESET' | 'STOP'
}
handleTipsMsg = (event: IpcMainEvent, {type}: TipsMsgData) => {
  if (type === 'CLOSE') {
    this.closeTipsWindow()
  } else if (type === 'RESET') {
    this.closeTipsWindow()
    this.send({
      type: 'resetTime'
    })
  } else if (type === 'STOP'){
    this.closeTipsWindow()
    this.send({
      type: 'stop'
    })
    this.menu.getMenuItemById('play').visible = true
    this.menu.getMenuItemById('pause').visible = false
  }
}
closeTipsWindow() {
  if (this.tipsWindow) {
    ipcMain.removeListener(TIPS_MESSAGE, this.handleTipsMsg)
    this.tipsWindow.close()
    this.tipsWindow = null
  }
}
复制代码
  1. 若是是关闭的消息,就关闭提示窗口。关闭时先去除事件的监听,而后关闭窗口和引用
  2. 若是是重置的消息,就关闭提示窗口,而后发消息通知计时器模块重置时间
  3. 若是是中止的消息,就关闭提示窗口,而后通知计时器模块中止计时,而后将托盘栏的菜单项进行调整:显示继续菜单项,隐藏暂停菜单项
建立提示气泡窗口

在browserWindow.ts中添加以下代码

const resolveUrl = (address: string) => `http://localhost:3000/#${address}`

export function createTipsWindow(rect: Rectangle, s: string): BrowserWindow {
  const win = new BrowserWindow({
    x: rect.x, // 窗口x坐标
    y: rect.y, // 窗口y坐标
    width: 300, // 窗口宽度
    height: 80, // 窗口高度
    alwaysOnTop: true, // 一直显示在最上面
    frame: false, // 无边框窗口
    resizable: false, // 不能够resize
    transparent: true, // 窗口透明
    webPreferences: {
      webSecurity: false, // 忽略web安全协议
      devTools: false, // 不开启 DevTools
      nodeIntegration: true // 将node注入到渲染进程
    }
  })
  // 加载Tips页面,传入消息通讯的事件名称和时间
  win.loadURL(resolveUrl(`/Tips?type=${TIPS_MESSAGE}&s=${s}`))
  return win
}
复制代码
vue 渲染进程代码: src/views/Tips.vue

页面结构很简单,提示用户还有几秒开始休息,而后提供暂停和关闭的按钮

<template>
  <div class="wrap">
    <div class="title">还剩{{time}}s开始休息~</div>
    <div class="progress"></div>
    <div class="btns">
      <button @click="stop">暂停</button>
      <button @click="reset">重置</button>
    </div>
  </div>
</template>
复制代码

主要看一下逻辑代码

<script>
import {ref} from 'vue'
import {useRoute} from 'vue-router'
const { ipcRenderer } = require('electron')

export default {
  setup() {
    // 取到当前页面的query参数
    const {query} = useRoute()
    // 使用传入的s做为时间
    const time = ref(query.s)
    // 向主进程发送消息
    const close = () => {
      ipcRenderer.send(query.type, {type: 'CLOSE'})
    }
    const stop = () => {
      ipcRenderer.send(query.type, {type: 'STOP'})
    }
    const reset = () => {
      ipcRenderer.send(query.type, {type: 'RESET'})
    }
    // 监听时间变化,修改时间
    ipcRenderer.on(query.type, (ipc, {s, done}) => {
      time.value = s
      if (done) close()
    })
    return {
      time,
      stop,
      reset
    }
  }
}
</script>
复制代码

为了节省篇幅,样式代码就不贴上来了,各位能够自行发挥,或者看下面的完整代码

到此,气泡提示的代码已经被咱们完成了。接下来咱们继续处理工做时间结束时,切换至休息时间的逻辑

切换休息时间
handleWorkTimeChange({ time: {h, m, s}, ms, done }: SendMsg) {
  // ...
  if (done) {
    this.toggleRest()
  }
}
toggleRest() {
  this.timeType = 'REST'
  this.closeTipsWindow()
  ipcMain.on(REST_MESSAGE, this.handleRestMsg)
  this.restWindows = createRestWindow()
}
复制代码
  1. 改变当前的timeType
  2. 关闭提示气泡窗口
  3. 监听锁屏渲染进程的事件
  4. 建立休息时间的窗口
监听事件
interface RestMsgData {
  type: 'CLOSE' | 'READY'
  data?: any
}
handleRestMsg = (event: IpcMainEvent, data: RestMsgData) => {
  if (data.type === 'READY') {
    this.startRestTime()
  } else if (data.type === 'CLOSE') {
    this.toggleWork()
  }
}
startRestTime = () => {
  this.send([
    {
      type: 'setTime',
      data: restTime
    },
    {
      type: 'run'
    }
  ])
}
toggleWork() {
  this.timeType = 'WORK'
  ipcMain.removeListener(REST_MESSAGE, this.handleRestMsg)
  this.restWindows?.forEach(win => {
    win.close()
  })
  this.restWindows = null
  this.startWorkTime()
}
复制代码

代码很简单,当渲染进程初始化成功后(vue create时机)会向咱们发送READY事件,此时咱们开始休息事件的倒计时。

当渲染进程的倒计时结束或者点击了关闭按钮时,会触发关闭事件,此时咱们将切换回工做时间

再说一下切换回工做时间的逻辑

  1. 切换timeType为工做时间
  2. 移除事件监听
  3. 关闭休息时间的窗口(注意这里的休息时间窗口是个数组,缘由咱们下面会说),解除引用
  4. 开始工做时间倒计时

喝口水接着来!建立休息时间的窗口(锁屏界面)

main/browserWindow.ts

export function createRestWindow(): BrowserWindow[] {
  return screen.getAllDisplays().map((display, i) => {
    // 建立浏览器窗口
    const win = new BrowserWindow({
      x: display.bounds.x + 50,
      y: display.bounds.y + 50,
      fullscreen: true, // 全屏
      alwaysOnTop: true, // 窗口是否应始终位于其余窗口的顶部
      closable: false, // 窗口是否可关闭
      kiosk: true, // kiosk模式
      vibrancy: 'fullscreen-ui', // 动画效果
      webPreferences: {
        devTools: false,
        webSecurity: false,
        nodeIntegration: true
      }
    })
    // 而且为你的应用加载index.html
    win.loadURL(resolveUrl(`/LockPage?type=${REST_MESSAGE}${i === 0 ? '&isMainScreen=1' : ''}&password=${password}`))
    return win
  })
}
复制代码

这个有几点须要特殊处理,由于咱们但愿出现锁屏界面时,用户就不能够进行别的操做了。

这里咱们须要启用kiosk模式来达到效果

windows中的kiosk模式介绍以下(取自百度):

什么是Windows自助终端模式? Windows Kiosk模式只是Windows操做系统(OS)的一项功能,它将系统的可用性或访问权限仅限于某些应用程序。意思是,当咱们在Windows上打开Kiosk模式时,它只容许一个应用程序运行,就像机场上的kiosk系统那样设置为仅运行Web浏览器,某些应用程序如PNR状态检查一个。 Kiosk模式的好处是,它容许企业仅在办公室,餐馆等运行特定的销售点(POS)应用程序,以阻止客户使用机器上的任何其余应用程序,除了他们已分配的应用程序。它不只能够在windows 10上使用,并且还能够在Windows XP,Windows Vista,Windows 7和Windows 8.1中启用。

简单点说就是让你的电脑只运行当前这个应用程序,阻止你使用别的应用程序。

主要的配置以下

fullscreen: true, // 窗口全屏
alwaysOnTop: true, // 窗口一直显示在最上面
closable: false, // 窗口不可关闭
kiosk: true, // 窗口为kiosk模式
复制代码

那代码中的screen.getAllDisplays()是干什么用的呢?这是为了防止外接显示器(程序员大多数都会外接的),若是咱们只建立一个窗口,那只能让当前屏幕没法操做,而别的显示器仍是能够正常工做的。因此咱们使用这个api来获取到全部的显示器,而后为每个显示器都建立一个窗口。

同时咱们只让第一个窗口中出现提示信息和关闭按钮。因此咱们给渲染进程传入一个主屏幕的标志。

vue渲染进程代码 views/LockPage.vue

<template>
  <div v-if="isMainScreen" class="wrap">
    <div class="time">{{time}}</div>
    <div class="btn" @click="close">X</div>
  </div>
</template>

<script>
export default {
  setup() {
    const {query} = useRoute()
    const time = ref('')
    const close = () => {
      ipcRenderer.send(query.type, {type: 'CLOSE'})
    }
    const isMainScreen = ref(!!query.isMainScreen)
    if (isMainScreen) {
      ipcRenderer.send(query.type, {type: 'READY'})
      ipcRenderer.on(query.type, (ipc, {time: {h, m, s}, done}) => {
        time.value = `${h}:${m}:${s}`
        if (done) close()
      })
    }
    return {
      isMainScreen,
      time,
      close
    }
  }
}
</script>
复制代码

逻辑很简单,若是是主屏幕,那初始化的时候咱们就发送一个ready事件,而后监听时间变化。若是时间结束就发送关闭的事件。

至此,就只剩设置相关的逻辑没有写

main/setting.ts

import fs from 'fs'
import path from 'path'

const storePath = path.resolve(__dirname, './store.json')

function get() {
  const store = fs.readFileSync(storePath, 'utf-8')
  return JSON.parse(store)
}

export let {restTime, tipsTime, workTime} = get()

export function setTime(rest: number, work: number, tips: number) {
  restTime = rest
  tipsTime = tips
  workTime = work
  fs.writeFileSync(storePath, JSON.stringify({restTime, tipsTime, workTime}, null, 2))
}
复制代码

逻辑:从本地文件中获取工做、休息、提示时间,当设置新的时间时再改写本地文件

设置窗口

完善托盘栏菜单项的代码

interface SettingMsgData {
  rest: number
  work: number
  tips: number
}
Menu.buildFromTemplate([
  {
    label: '偏好设置',
    accelerator: 'CmdOrCtrl+,',
    click: () => {
      const win = createSettingWindows(restTime, tipsTime, workTime)
      const handleSettingMsg = (event: IpcMainEvent, {rest, work, tips}: SettingMsgData) => {
        setTime(rest, work, tips)
        win.close()
      }
      win.on('close', () => {
        ipcMain.removeListener(SETTING_MESSAGE, handleSettingMsg)
      })
      ipcMain.on(SETTING_MESSAGE, handleSettingMsg)
    },
  }
])
复制代码

当咱们点击设置菜单项时

  1. 建立一个设置窗口
  2. 监听设置窗口发送来的消息
  3. 当设置窗口关闭时移除消息监听

而设置窗口发消息的时机就是当用户点击保存的时候,此时会把设置以后的工做时间、休息时间、提示时间传过来。咱们设置到本地便可

下面咱们看一下建立窗口和渲染进程的逻辑

main/browserWindow.ts

export function createSettingWindows(restTime: number, tipsTime: number, workTime: number) {
  const win = new BrowserWindow({
    maximizable: false,
    minimizable: false,
    resizable: false,
    webPreferences: {
      webSecurity: false,
      nodeIntegration: true // 将node注入到渲染进程
    }
  })
  win.loadURL(resolveUrl(`/Setting?type=${SETTING_MESSAGE}&rest=${restTime}&tips=${tipsTime}&work=${workTime}`))
  return win
}
复制代码

vue: views/setting.vue

export default {
  setup() {
    const {query} = useRoute()
    const rest = ref(+query.rest / MINUTES)
    const work = ref(+query.work / MINUTES)
    const tips = ref(+query.tips / SECONDS)
    const save = () => {
      ipcRenderer.send(query.type, {
        rest: rest.value * MINUTES,
        work: work.value * MINUTES,
        tips: tips.value * SECONDS
      })
    }
    const reset = () => {
      rest.value = +query.rest / MINUTES
      work.value = +query.work / MINUTES
      tips.value = +query.tips / SECONDS
    }
    return {
      rest,
      work,
      tips,
      save,
      reset
    }
  }
}
复制代码

好了,至此咱们的代码已经彻底实现了。

可是如今还有一个点须要解决,那就是电脑休眠时,咱们应该让计时功能暂停。

咱们在main/index.ts中修改以下代码

app.whenReady().then(() => {
  const tray = initTray()
  // 系统挂起
  powerMonitor.on('suspend', () => {
    tray.send({
      type: 'stop'
    })
  })
  // 系统恢复
  powerMonitor.on('resume', () => {
    tray.send({
      type: 'run'
    })
  })
})
复制代码

好了,就是监听两个事件的事~ 都是些api,就很少说了。

接下来咱们打包一下electorn,让咱们的代码能够在电脑上安装。

项目打包

项目打包主流的方式有两种:electron-builderelectron-packager

electron-builder会把项目打成安装包,就是咱们平时安装软件的那种形式。

electron-packager会把项目打包成可执行文件,你能够理解为上面👆的安装包安装以后的软件目录。

下面咱们分别介绍一下这两种的打包步骤(这里只打包了mac版本,win版本可自行查阅官网,差异不大)

electron-builder打包

安装

cnpm i electron-builder --save-dev
复制代码

package.json新增build

"build": {
  // 软件的惟一id
  "appId": "rest.time.lay4519",
  // 软件的名称
  "productName": "Lay",
  // 要打包的文件
  "files": [
    "node_modules/",
    "dist/",
    "package.json"
  ],
  // 打包成mac 安装包
  "dmg": {
    "contents": [
      {
        "x": 130,
        "y": 220
      },
      {
        "x": 410,
        "y": 220,
        "type": "link",
        "path": "/Applications"
      }
    ]
  },
  // 设置打包目录
  "directories": {
    "output": "release"
  }
}
复制代码

增长脚本

"scripts": {
  // ...
  "buildMac": "cp -r icon dist/icon && npx electron-builder --mac --arm64"
}
复制代码

electron-packager打包

增长脚本

"scripts": {
  // ...
  "packageMac": "rm -rf ./dist && npx vite build && tsc && cp -r icon dist/icon & cp main/store.json dist/main/store.json && electron-packager . --overwrite"
}
复制代码

这个大概解释一下

  1. 清空dist目录
  2. 使用vite build渲染进程代码
  3. tsc编译主进程代码
  4. 拷贝icon文件夹、main/store.json
  5. electron-packager 打包当前文件夹

好了,打包已经完成了。可是你觉得到此就结束了吗?

点开vite打包后的index.html,你会发现script标签上有一个type="module",这意味着vite默认打包后,仍是使用了es6的模块机制,这个机制依赖了http,因此咱们没法使用file协议来加载文件。

也就是说,这个html咱们双击打开是没法运行的,因此你在electron里直接loadFile也是没法运行的。

怎么解决呢?也许vite能够配置CMD、AMD的模块机制,可是我也懒得再去翻阅文档了。反正是用的electron,咱们直接在本地起一个http服务就是

main/browserWindow.ts

const productPort = 0
const resolveUrl = (address: string) => `http://localhost:${isDev ? 3000 : productPort}/#${address}`

if (!isDev) {
 // 检测端口是否被占用
  const portIsOccupied = (port: number): Promise<number> => {
    return new Promise(r => {
      const validate = (p: number) => {
        const server: http.Server = http
          .createServer()
          .listen(p)
          .on('listening', () => {
            server.close()
            r(p)
          })
          .on('error', (err: any) => {
            if (err.code === 'EADDRINUSE') {
              server.close()
              validate(p += 1)
            }
          })
      }
      validate(port)
    })
  }
  // 执行
  portIsOccupied(8981)
    .then((p) => {
      productPort = p
      http.createServer((req, res) => {
        if (req.url === '/') {
          // content-type: application/javascript
          return fs.readFile(path.resolve(__dirname, '..', 'renderer/index.html'), (err, data) => {
            if (err) return
            res.setHeader('content-type', 'text/html; charset=utf-8')
            res.end(data)
          })
        } else {
          return fs.readFile(path.resolve(__dirname, '..', 'renderer' + req.url), (err, data) => {
            if (err) return
            if (req.url!.endsWith('.js')) {
              res.setHeader('content-type', 'application/javascript')
            } else if (req.url!.endsWith('.css')) {
              res.setHeader('content-type', 'text/css')
            }
            // 缓存7天
            res.setHeader('cache-control', 'max-age=604800')
            res.end(data)
          })
        }
      })
      .listen(p)
    })
}
复制代码

好啦,这下咱们就真正的把代码完成了~

完整代码点此,以为文章还能够的欢迎star、following。

若是有什么问题欢迎在评论区提出讨论。感谢观看🙏

相关文章
相关标签/搜索