技术探索Electron实现桌面端计时器
Breezli吐槽
最初技术栈为Vue+Electron+TS,太甜美的难了。。。配置报错问题一大堆,一路报错问AI一路改,长达三个小时把文件改的乱七八糟无法收尾忍痛放弃新开项目
**最后技术栈选择为 **Vue+Electron+Vite+JS ,包管理工具选择为 pnpm
完整代码见github仓库 ......
(还没推,因为dist文件太大的原因没办法传,目前还在寻找更合理的推送方案)
主步骤
初始化
创建
1
| pnpm create vite Tclick --template vue
|
依赖
package.json 直享版
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60
| { "name": "clock", "private": true, "version": "0.0.0", "description": "An Timer application.",//描述 "author": "none <你的邮箱>",//作者 "homepage": "./", "main": "main.js", "scripts": { "dev": "vite", "build": "vite build", "serve": "vite preview", "electron:dev": "concurrently \"cross-env NODE_ENV=development wait-on http://localhost:5173 && electron .\" \"npm run dev\"", "electron:build": "npm run build && electron-builder && node post-build.js", "start": "npm run electron:dev" }, "dependencies": { "vue": "^3.5.13" }, "devDependencies": { "@vitejs/plugin-vue": "^5.2.1", "concurrently": "^9.1.2", "cross-env": "^7.0.3", "electron": "^33.3.1", "electron-builder": "^25.1.8", "vite": "^6.0.5", "vite-plugin-html": "^3.2.2", "wait-on": "^8.0.2" }, "build": { "productName": "Clock", "appId": "com.example.clock", "directories": { "output": "release" }, "files": [ "dist/**/*", "node_modules/**/*", "main.js", "preload.js" ], "mac": { "target": "dmg", "icon": "dist/installer-icon-256.ico" }, "win": { "target": "nsis", "icon": "dist/installer-icon-256.ico" }, "linux": { "target": "AppImage", "icon": "dist/installer-icon-256.ico" }, "nsis": { "installerIcon": "./public/installer-icon.ico",//这里的ico是我下的时钟图标,替换软件图标 "oneClick": false, "allowToChangeInstallationDirectory": true } } }
|
补充项目结构
public/icon
(图标)
main.js
(Electron主进程)
preload.js
(Electron预加载脚本)
post-build.js
(文件操作,生成dist)
核心配置
index.html
1 2 3 4 5 6 7 8 9 10 11 12 13
| <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8" /> <link rel="icon" type="image/svg+xml" href="./dist/installer-icon.ico" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>Clock</title> </head> <body> <div id="app"></div> <script type="module" src="/src/main.js"></script> </body> </html>
|
main.js
(Electron主进程)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66
| const { app, BrowserWindow } = require('electron') const path = require('path')
function createWindow() { const win = new BrowserWindow({ width: 500, height: 210, x: 800, // 指定初始 x 坐标 y: 20, // 指定初始 y 坐标 webPreferences: { preload: path.join(__dirname, 'preload.js'), nodeIntegration: false, contextIsolation: true, // 设置 CSP additionalArguments: ['--disable-features=ElectronSandbox'], // 根据需要调整 webviewTag: false, // 禁止使用 <webview> 标签 webSecurity: true, sandbox: true, contentSecurityPolicy: ` default-src 'self'; script-src 'self'; connect-src 'self'; style-src 'self' 'unsafe-inline'; /* 如果需要内联样式 */ img-src 'self' data:; font-src 'self'; `, }, title: 'Clock', // 设置窗口标题 icon: path.join(__dirname, 'dist', 'installer-icon.ico'), // 设置窗口图标 autoHideMenuBar: true, //自动隐藏菜单栏 alwaysOnTop: true, // 确保窗口始终在顶部 // frame: false, // 隐藏窗口边框 // resizable: false, // 禁止调整窗口大小 transparent: true, // 使窗口透明 // skipTaskbar: true, // 跳过任务栏显示 })
if (process.env.NODE_ENV === 'development') { win.loadURL('http://localhost:5173') // win.loadFile('dist/index.html') } else { win.loadFile(path.resolve(__dirname, 'dist', 'index.html')) } }
app.whenReady().then(() => { createWindow()
app.on('activate', function () { if (BrowserWindow.getAllWindows().length === 0) createWindow() }) })
// 其他事件监听器...
app.on('window-all-closed', () => { if (process.platform !== 'darwin') { app.quit() } })
app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) { createWindow() } })
|
preload.js
(Electron预加载脚本)
1 2 3 4
| // preload.js window.addEventListener('DOMContentLoaded', () => { // 这里可以添加与DOM交互的代码 })
|
post-build.js
(文件操作,生成dist)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| const fs = require('fs') const path = require('path')
const htmlFilePath = path.resolve(__dirname, 'dist', 'index.html')
fs.readFile(htmlFilePath, 'utf8', (err, data) => { if (err) { return console.log(err) }
const result = data.replace(/<title>.*?<\/title>/, '<title>Clock</title>')
fs.writeFile(htmlFilePath, result, 'utf8', (err) => { if (err) return console.log(err) }) })
|
简陋的组件
App.vue
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| <template> <div id="app" class="hide-scrollbar"> <Main /> </div> </template>
<script setup> import Main from './components/index.vue' </script>
<style scoped> .hide-scrollbar { scrollbar-width: none; /* Firefox */ -ms-overflow-style: none; /* IE 和 Edge */ }
.hide-scrollbar::-webkit-scrollbar { display: none; } </style
|
utils/timeUtils.js
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| export const getFormattedTime = () => { const now = new Date(); // 获取当前时间的小时、分钟和秒数,并确保它们都是两位数 const padZero = (num) => num.toString().padStart(2, '0'); const hours = padZero(now.getHours()); const minutes = padZero(now.getMinutes()); const seconds = padZero(now.getSeconds());
return { hoursFirstChar: hours[0], hoursSecondChar: hours[1], minutesFirstChar: minutes[0], minutesSecondChar: minutes[1], secondsFirstChar: seconds[0], secondsSecondChar: seconds[1], }; }
|
components / index.vue
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122
| <template> <div class="layouts"> <div v-for="(title, index) in titles" :key="index" class="layout"> <div></div> <div class="top"> <div class="title">{{ title }}</div> <div class="btn" @click="start(index)" :disabled="timers[index].isRunning">开始</div> <div class="btn" @click="stop(index)" :disabled="!timers[index].isRunning">停止</div> <div class="btn" @click="reset(index)" :disabled="timers[index].elapsedTime === 0">重置</div> </div> <!-- <Flip :hours="timers[index].formattedTime.hours" :minutes="timers[index].formattedTime.minutes" :seconds="timers[index].formattedTime.seconds" /> --> <Timer /> </div> </div> </template>
<script setup> import { ref, computed } from 'vue' import Flip from './Flip/index.vue' import Timer from './Timer/index.vue'
const titles = ['学习时长', '英语时长', '看书时长', '博客时长'] const timers = ref([])
for (let i = 0; i < 4; i++) { timers.value.push({ startTime: null, elapsedTime: 0, isRunning: false, formattedTime: { hours: '00', minutes: '00', seconds: '00' } }) }
const start = (index) => { if (!timers.value[index].isRunning) { timers.value[index].startTime = Date.now() - (timers.value[index].startTime ? timers.value[index].startTime : 0) timers.value[index].isRunning = true updateElapsedTime(index) } }
const stop = (index) => { timers.value[index].isRunning = false }
const reset = (index) => { timers.value[index].startTime = null timers.value[index].elapsedTime = 0 timers.value[index].isRunning = false }
const updateElapsedTime = (index) => { if (timers.value[index].isRunning) { timers.value[index].elapsedTime = Math.floor((Date.now() - timers.value[index].startTime) / 1000) updateFormattedTime(index) setTimeout(() => updateElapsedTime(index), 1000) } }
const updateFormattedTime = (index) => { const hours = Math.floor(timers.value[index].elapsedTime / 3600).toString().padStart(2, '0') const minutes = Math.floor((timers.value[index].elapsedTime % 3600) / 60).toString().padStart(2, '0') const seconds = (timers.value[index].elapsedTime % 60).toString().padStart(2, '0') timers.value[index].formattedTime = { hours, minutes, seconds } } </script>
<style scoped> .layouts { display: flex; flex-direction: row; gap: 20px; }
.layout { display: flex; flex-direction: column; align-items: center; width: 25%; }
.layout .top { display: flex; justify-content: space-between; align-items: center; width: 100%; background: #fff; border-radius: 20px; padding: 10px; box-sizing: border-box; }
.layout .top .title { font-size: 20px; font-weight: 700; color: #333; margin-right: auto; }
.layout .top .btn { display: flex; justify-content: center; align-items: center; width: 50px; height: 24px; border-radius: 10px; border: 1px solid #333; font-size: 14px; font-weight: 700; color: #333; padding: 0px 3px; cursor: pointer; }
.layout .top .btn:hover { background: #333; color: #fff; } </style>
|
components / Flip / index.vue
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111
| <template> <div class="clock"> <!-- 小时 --> <div class="flip"> <div class="digital" :data-number="hoursFirstChar">{{ hoursFirstChar }}</div> </div> <div class="flip"> <div class="digital" :data-number="hoursSecondChar">{{ hoursSecondChar }}</div> </div> <em class="divider">:</em>
<!-- 分钟 --> <div class="flip"> <div class="digital" :data-number="minutesFirstChar">{{ minutesFirstChar }}</div> </div> <div class="flip"> <div class="digital" :data-number="minutesSecondChar">{{ minutesSecondChar }}</div> </div> <em class="divider">:</em>
<!-- 秒 --> <div class="flip"> <div class="digital" :data-number="secondsFirstChar">{{ secondsFirstChar }}</div> </div> <div class="flip"> <div class="digital" :data-number="secondsSecondChar">{{ secondsSecondChar }}</div> </div> </div> </template>
<script setup> import { ref, onMounted, watch } from 'vue'; import { getFormattedTime } from '../../utils/timeUtils'; // 确保路径正确
const hoursFirstChar = ref(''); const hoursSecondChar = ref(''); const minutesFirstChar = ref(''); const minutesSecondChar = ref(''); const secondsFirstChar = ref(''); const secondsSecondChar = ref('');
const updateTime = () => { const time = getFormattedTime(); hoursFirstChar.value = time.hoursFirstChar; hoursSecondChar.value = time.hoursSecondChar; minutesFirstChar.value = time.minutesFirstChar; minutesSecondChar.value = time.minutesSecondChar; secondsFirstChar.value = time.secondsFirstChar; secondsSecondChar.value = time.secondsSecondChar; };
onMounted(() => { updateTime(); // 初始化时更新时间 setInterval(updateTime, 1000); // 每秒更新一次时间 }); </script>
<style scoped> body { display: flex; justify-content: center; align-items: center; height: 100vh; width: 100vw; background-color: #2c3e50; /* 添加背景色以便于查看 */ }
.clock { display: flex; }
/* 时钟的分隔 */ .clock .divider { font-size: 30px; line-height: 60px; font-style: normal; color: rgb(255, 255, 255); /* 更改为白色以便于查看 */ margin: 0 5px; }
/* 时钟的卡片 */ .clock .flip { position: relative; width: 40px; /* 稍微增加宽度以确保数字完全显示 */ height: 60px; /* 增加高度以确保数字完全显示 */ margin: 2px; font-size: 48px; /* 增加字体大小以便于查看 */ font-weight: 700; line-height: 60px; /* 确保文本垂直居中 */ text-align: center; background: rgb(46, 45, 45); border: 1px solid rgb(34, 33, 33); border-radius: 10px; box-shadow: 0 0 6px rgba(54, 54, 54, 0.5); }
/* 时钟上的数字 */ .clock .flip .digital { position: absolute; top: 0; left: 0; right: 0; bottom: 0; color: white; /* 数字颜色为白色 */ background: transparent; /* 背景透明 */ border-radius: 10px; display: flex; align-items: center; justify-content: center; } </style>
|
components / Timer / index.vue
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70
| <template> <div> <h1>{{ formattedTime }}</h1> <button @click="start" :disabled="isRunning">开始</button> <button @click="stop" :disabled="!isRunning">停止</button> <button @click="reset" :disabled="elapsedTime === 0">重置</button> </div> </template>
<script setup> import { ref, computed, onMounted, onUnmounted } from 'vue'
const startTime = ref(null) const elapsedTime = ref(0) const isRunning = ref(false)
const formattedTime = computed(() => { const hours = Math.floor(elapsedTime.value / 3600).toString().padStart(2, '0') const minutes = Math.floor((elapsedTime.value % 3600) / 60).toString().padStart(2, '0') const seconds = (elapsedTime.value % 60).toString().padStart(2, '0') return `${hours}:${minutes}:${seconds}` })
let intervalId = null
function start() { if (!isRunning.value) { startTime.value = Date.now() - (startTime.value ? startTime.value : 0) isRunning.value = true intervalId = setInterval(updateElapsedTime, 1000) } }
function stop() { clearInterval(intervalId) isRunning.value = false }
function reset() { clearInterval(intervalId) startTime.value = null elapsedTime.value = 0 isRunning.value = false }
function updateElapsedTime() { elapsedTime.value = Math.floor((Date.now() - startTime.value) / 1000) }
onMounted(() => { document.addEventListener('visibilitychange', handleVisibilityChange) })
onUnmounted(() => { document.removeEventListener('visibilitychange', handleVisibilityChange) clearInterval(intervalId) })
function handleVisibilityChange() { if (document.visibilityState === 'hidden') { if (isRunning.value) { stop() } } else if (document.visibilityState === 'visible') { if (isRunning.value) { start() } } } </script>
|
启动项目
网页端开发环境
桌面端开发环境
.exe文件生成位置
release
文件夹中的 Setup.exe(文件有70多m!!!不理解)
TODO
1.桌面软件图标还是Electron的,需要修改为下好的ico
2.git大文件提交问题(可能是需要改动gitignore?没有接触过)
3.第一版Vue+Electron+TS项目提到过自动发布版本工作流,还有检测标签啥的,还没弄懂这周探索一下,没记错的话github主页的贪吃蛇好像也和这个有关系
4.简单UI排布
5.交互功能:用户可自行添加多个计时器,且独立计时
6.考虑到挂后台会有计时误差,添加新的计时逻辑(begin记录时间戳,从后台调出时通过时间戳相减更新时间)