为了免受再来一刀的痛苦,我耗时两天开发了一款《提肛助手》

有熟悉我的兄弟发现我最近没咋更新,其实是住院了。这是一个痛苦的故事,痛苦到什么程度呢?

大概是一种"坐立难安"的赶脚~

本来以为是小问题,但是发现有血,遂去医院看了看。主任推了推眼镜,一口断定:肛周脓肿,下午能住院吗?需要手术!

这一刻,我宛如晴天霹雳,裤擦一声。

啥?得住院?还得手术?

于是乎,经过简单的沟通,我住进了医院。第二天被推进手术室,接受了"温柔"的一刀。

兄弟们,我现在拖着我的病"腚",痛心疾"腚"的告诉大家:一定要爱惜身体,拒绝辣椒,不要久坐

经过7天的住院,我终于返回了家中。临别前,医生告诉我:你在家可以做提肛运动,有助于术后的恢复。

但是我又老忘,于是乎我开发了一款桌面端软件------提肛助手


使用 Tauri + Vite + Vue3 + Ts 进行开发,支持 Windows、MacOS、Linux、Android、IOS 多终端安装。

采用扁平化的设计风格,搭配 Rust 操作系统的强大能力,实现了数据缓存、系统托盘、定时提醒等多个功能。

下面简单讲一下设计思路和实现方案,以及具体的实现代码。需要访问整个项目,可以访问这个地址

设计思路

整个需求比较简单:

  1. 设定一个目标值。
  2. 通过点击按钮的方式,记录每天运动次数。
  3. 通过可量化的方式展示运动次数距离目标值的情况,直观查看。
  4. 将数据缓存起来,免得每次都需要重新输入。
  5. 定时提醒运动,通过倒计时的方式,每N分钟以后提醒一次用户。
  6. 提醒弹窗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>

数据缓存

数据缓存可选择的方案比较多,可以用传统的 localStoreSessionStore,甚至是 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"
]

倒计时

倒计时在桌面端的项目中一般有两种方案:

  1. 纯前端方案,通过 setInterval 不断调用当前程序与系统时间进行对比,如果达成时间则触发提示。
  2. 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);
    }
});

总结

里面还有很多的实现细节文章中不能一一详尽,有需要的兄弟们可以看一下这个项目,里面的注释相对是比较全面的。

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

相关推荐
红尘散仙3 小时前
使用 Tauri Plugin-Store 实现 Zustand 持久化与多窗口数据同步
前端·rust·electron
沙白猿3 小时前
npm启动项目报错“无法加载文件……”
前端·npm·node.js
tyro曹仓舒3 小时前
彻底讲透as const + keyof typeof
前端·typescript
蛋黄液3 小时前
【黑马程序员】后端Web基础--Maven基础和基础知识
前端·log4j·maven
睡不着的可乐3 小时前
uniapp 支付宝小程序 扩展组件 component 节点的class不生效
前端·微信小程序·支付宝
前端小书生3 小时前
React Router
前端·react.js
_大学牲4 小时前
Flutter Liquid Glass 🪟魔法指南:让你的界面闪耀光彩
前端·开源
Miss Stone4 小时前
css练习
前端·javascript·css
Nicholas684 小时前
flutter视频播放器video_player_avfoundation之FVPVideoPlayer(二)
前端