有熟悉我的兄弟发现我最近没咋更新,其实是住院了。这是一个痛苦的故事,痛苦到什么程度呢?
大概是一种"坐立难安"的赶脚~
本来以为是小问题,但是发现有血,遂去医院看了看。主任推了推眼镜,一口断定:肛周脓肿,下午能住院吗?需要手术!
这一刻,我宛如晴天霹雳,裤擦一声。
啥?得住院?还得手术?

于是乎,经过简单的沟通,我住进了医院。第二天被推进手术室,接受了"温柔"的一刀。
兄弟们,我现在拖着我的病"腚",痛心疾"腚"的告诉大家:一定要爱惜身体,拒绝辣椒,不要久坐。
经过7天的住院,我终于返回了家中。临别前,医生告诉我:你在家可以做提肛运动,有助于术后的恢复。
但是我又老忘,于是乎我开发了一款桌面端软件------提肛助手。

使用 Tauri + Vite + Vue3 + Ts 进行开发,支持 Windows、MacOS、Linux、Android、IOS 多终端安装。
采用扁平化的设计风格,搭配 Rust 操作系统的强大能力,实现了数据缓存、系统托盘、定时提醒等多个功能。
下面简单讲一下设计思路和实现方案,以及具体的实现代码。需要访问整个项目,可以访问这个地址。
设计思路
整个需求比较简单:
- 设定一个目标值。
- 通过点击按钮的方式,记录每天运动次数。
- 通过可量化的方式展示运动次数距离目标值的情况,直观查看。
- 将数据缓存起来,免得每次都需要重新输入。
- 定时提醒运动,通过倒计时的方式,每N分钟以后提醒一次用户。
- 提醒弹窗5秒后自动关闭,避免出现长时间提醒。
实现方案及实现代码
直观展示运动进度

首选的是采用 Echarts 等现成的图表插件绘制,但是看了看 Echarts 的大小,想了想还是决定放弃了。
使用 Canvas 绘制的效果也非常好,但是写了一版发现遮罩效果比较费劲,遂放弃。
最后使用 多个色块div
组合成背景,配合 遮罩层div+opacity
遮挡效果实现进度可视化。
小技巧 : 通过缩短遮罩层的 width
来透出底部背景的色块,同时设置 right: 0;
让遮罩层从左往右逐渐缩短,达到展示效果。
实现代码
html
<!-- 进度条 -->
<div class="progress-wrap">
<div class="progress-track">
<div class="progress-segment-wrap">
<div class="progress-segment0"></div>
<div class="progress-segment1"></div>
...
<div class="progress-segment9"></div>
</div>
<div class="progress-bar" :style="{ width: progressPercent + '%' }"></div>
</div>
</div>
<style>
.progress-track {
width: 80%;
height: 12px;
margin: 0 auto;
border-radius: 6px;
overflow: hidden;
position: relative;
}
.progress-segment-wrap {
width: 100%;
height: 100%;
display: flex;
gap: 2px;
}
.progress-bar {
width: 100%;
height: 100%;
/* 关键代码 start */
opacity: 0.7;
position: absolute;
top: 0;
right: 0;
/* 关键代码 end */
}
.progress-segment0,
.progress-segment1,
...
.progress-segment9 {
flex: 1;
height: 100%;
}
/* 给每个segment都设置一个颜色 */
.progress-segment0 {
background-color: #8382FF;
}
...
</style>
<script>
// 计算进度百分比
const progressPercent = computed((): number => {
if (sum.value === 0) return 0;
return (1 - (count.value / sum.value)) * 100;
});
</script>
数据缓存

数据缓存可选择的方案比较多,可以用传统的 localStore
、SessionStore
,甚至是 IndexedDB
。还可以采用桌面端常用的方案:自定义数据保存文件
、使用 Store 插件等
。
因为 Tauri 框架自带一个官方的 Store
插件,所以这里直接使用 Store 插件。
缓存的数据也是传统的 Key / Value
的形式。
实现代码
首先安装 @tauri-apps/plugin-store
插件。
注意 : 有时候直接安装可能存在问题,需要手动设置一下 lib.rs
。
js
import { Store } from '@tauri-apps/plugin-store';
let store: Store = null;
store = await Store.load('store.bin');
// 新增
await store.set('target-num', { value: 200 });
// 查询
store.has('target-num')
// 获取
await store.get('target-num');
最最重要的一点是:一定要设置 Store
的权限,没有权限 Store
是无法生效的。
js
"permissions": [
...
"store:allow-has",
"store:allow-get",
"store:allow-set",
"store:allow-save",
"store:allow-load"
]
倒计时

倒计时在桌面端的项目中一般有两种方案:
- 纯前端方案,通过 setInterval 不断调用当前程序与系统时间进行对比,如果达成时间则触发提示。
- Rust+前端方案,通过 Rust 创建异步任务,触发倒计时以后向前端发送触发事件,由前端触发弹窗。
需要注意的是,第一种方案固然简单,但是系统开销相对来讲比较大,而且受进程影响比较大,前端的时间计算一般不推荐使用。所以选择了第二种方案实现。
开启倒计时以后还需要停止倒计时,在第二次触发的时候方便重新进行计算。并且前端也需要在页面卸载的时候触发停止倒计时,避免出现多重计算的情况。
实现代码
rust
/**
* 启动倒计时方法
* 参数:
* app: AppHandle
* interval_date: u64 - 倒计时分钟数
* */
#[tauri::command]
pub async fn start_countdown(app: AppHandle, interval_date: u64) {
if COUNTDOWN_RUNNING.load(Ordering::Relaxed) {
stop_countdown(app.clone()).await;
}
COUNTDOWN_RUNNING.store(true, Ordering::Relaxed);
let app_clone = app.clone();
let interval_minutes = interval_date;
let handle = tokio::spawn(async move {
while COUNTDOWN_RUNNING.load(Ordering::Relaxed) {
// 倒计时指定分钟数
let sleep_duration = Duration::from_secs(interval_minutes * 60);
// 分段睡眠,以便能够及时响应停止信号
let check_interval = Duration::from_secs(1);
let total_seconds = interval_minutes * 60;
for _ in 0..total_seconds {
if !COUNTDOWN_RUNNING.load(Ordering::Relaxed) {
return; // 如果被停止,直接退出
}
sleep(check_interval).await;
}
if COUNTDOWN_RUNNING.load(Ordering::Relaxed) {
println!("倒计时 {} 分钟完成,发送time_up事件", interval_minutes);
}
}
});
// 保存任务句柄
unsafe {
if COUNTDOWN_HANDLE.is_none() {
COUNTDOWN_HANDLE = Some(Arc::new(Mutex::new(None)));
}
if let Some(ref handle_mutex) = COUNTDOWN_HANDLE {
if let Ok(mut guard) = handle_mutex.lock() {
*guard = Some(handle);
}
}
}
}
/**
* 停止倒计时方法
*/
#[tauri::command]
pub async fn stop_countdown(app: AppHandle) {
println!("停止倒计时");
let app_clone = app.clone();
// 设置停止标志
COUNTDOWN_RUNNING.store(false, Ordering::Relaxed);
// 取消正在运行的任务
unsafe {
if let Some(ref handle_mutex) = COUNTDOWN_HANDLE {
if let Ok(mut guard) = handle_mutex.lock() {
if let Some(handle) = guard.take() {
handle.abort();
// 向前端发送取消任务的事件
if let Err(e) = app_clone.emit("time_finished", "") {
println!("发送取消任务事件失败: {}", e);
}
println!("倒计时任务已取消");
}
}
}
}
}
在外部文件定义的方法需要在 lib.rs
中注册一下,才能被前端访问到。
rust
...
commands::start_countdown,
...
commands::stop_countdown,
commands::show_main_window
])
...
前端想要访问 Rust 中的方法需要使用 invoke
方法。
js
import { invoke } from '@tauri-apps/api/core';
/** 启动倒计时任务 */
const handleIntervalStart = async (): Promise<void> => {
if (!store) return;
try {
let intervalData: any = await store.get('interval-time');
await invoke('start_countdown', { intervalDate: intervalData.value });
isStart.value = true;
} catch (error) {
console.error('启动倒计时失败:', error);
}
}
在传参的时候需要注意,如果是定义的 ref
则一定要记得传 .value
,Rust 函数的形参有严格的类型校验。直接传对象过去会报错。
自定义窗口

通过 Rust 监听倒计时,触发以后向前端抛事件,前端通过 listen
方法监听事件。
这里建议写个监听器,防止后面还有额外的用处。虽然我这儿没用到。
创建新窗口 Tauri 提供了两个方案,一个是 window + webView
,另一个是 webViewWindow
,这俩不是一回事儿哈。
我这里用的是 webviewWindow
只是为了减少点儿代码量,其实没区别。如果需要精细控制窗口和页面渲染,建议选择 window + webView
,没有特殊需求直接选 webviewWindow
即可。
这里还需要注意一点,自定义弹窗内部的页面 notification.html
我是直接放在 /项目
下了,和 index.html
同级。
但是我不建议这么放,因为打包的时候 Vite 不会把 notification.html
放在 /dist
中,如果你像我一样,还需要再 /public
中复制一份,否则访问不到。
实现代码
js
// 监听倒计时更新事件
unlistenCountdownUpdate = await listen('time_up', async () => {
console.log('触发弹窗提醒,倒计时到期!');
// 获取屏幕尺寸来计算右下角位置
const screenWidth = window.screen.availWidth;
const screenHeight = window.screen.availHeight;
const windowWidth = 320;
const windowHeight = 150;
try {
// 使用 WebviewWindow 来创建带有 URL 的窗口
const { WebviewWindow } = await import('@tauri-apps/api/webviewWindow');
const notificationWindow = new WebviewWindow('notification-' + Date.now(), {
url: '/notification.html',
width: windowWidth,
height: windowHeight,
x: screenWidth - windowWidth - 20,
y: screenHeight - windowHeight - 40,
resizable: false,
minimizable: false,
maximizable: false,
alwaysOnTop: true,
skipTaskbar: true,
decorations: false,
transparent: true,
shadow: false,
center: false,
dragDropEnabled: true,
title: '提肛提醒'
});
notificationWindow.once('tauri://created', () => {
console.log('通知窗口创建成功');
});
notificationWindow.once('tauri://error', (error) => {
console.error('通知窗口创建失败:', error);
});
} catch (error) {
console.error('创建通知窗口时发生错误:', error);
}
});
总结
里面还有很多的实现细节文章中不能一一详尽,有需要的兄弟们可以看一下这个项目,里面的注释相对是比较全面的。

另外最重要的一点,一定要保护好自己的"屁股",因为真的很疼!!!