[實戰] 使用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。

若是有什麼問題歡迎在評論區提出討論。感謝觀看🙏

相關文章
相關標籤/搜索