Electron实现桌面端计时器

吐槽

最初技术栈为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
}
}
}
1
pnpm install

补充项目结构

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>

启动项目

1
pnpm run build

网页端开发环境

1
pnpm run dev

桌面端开发环境

1
pnpm run start

.exe文件生成位置

release文件夹中的 Setup.exe(文件有70多m!!!不理解)

TODO

1.桌面软件图标还是Electron的,需要修改为下好的ico

2.git大文件提交问题(可能是需要改动gitignore?没有接触过)

3.第一版Vue+Electron+TS项目提到过自动发布版本工作流,还有检测标签啥的,还没弄懂这周探索一下,没记错的话github主页的贪吃蛇好像也和这个有关系

4.简单UI排布

5.交互功能:用户可自行添加多个计时器,且独立计时

6.考虑到挂后台会有计时误差,添加新的计时逻辑(begin记录时间戳,从后台调出时通过时间戳相减更新时间)