用vue3写了个牛马时钟,集成到小程序,已上线

3,273 阅读5分钟

我最近做了一个小工具,叫「牛马时钟」,已经上线微信小程序了。今天想和大家分享这个项目的来龙去脉,以及开发过程中的一些技术细节。

一、问题的起点:打工人的时间困惑

每天上班,我总会想一个问题:我的时间到底值多少钱?
月薪 1 万,每天工作 8 小时,一个月 22 个工作日——数学好的人可以立刻算出时薪:10000/(22×8)≈56.8 元/小时。但问题是,这个数字只是静态的。当我实际坐在工位上时,时间的流逝是动态的:每过 1 分钟,我赚了 0.95 元;每刷 10 分钟手机,就“亏”了 9.5 元。

我需要一个工具,能实时显示时间与收益的关系,让“摸鱼的代价”变得肉眼可见。这就是「牛马时钟」的核心需求。

二、功能设计:从需求到落地

工具的功能很简单,分两步:

  1. 1. 输入基础数据:月薪、每日工时、当月工作日数(比如 22 天)。
  2. 2. 实时计时:启动后,页面会显示已工作时间(时:分:秒)和累计收益(精确到分)。

举个例子:输入月薪 8800 元、每日 8 小时、22 天工作日,时薪就是 50 元/小时。工作 1 小时 15 分钟,收益就是 50 + (15/60)×50 = 62.5 元。

为了让用户不用重复输入,数据需要本地持久化——下次打开小程序时,自动读取上次的输入。

三、技术选型:Vue3 + uni-app 的组合

我选择 Vue3 作为前端框架,主要是因为它的组合式 API(Composition API)。相比 Vue2 的选项式 API,组合式 API 更适合逻辑复用和代码组织。比如,计时器、数据计算这些独立功能,可以封装成独立的 composables,代码结构更清晰。

跨平台方面,我用了 uni-app。它能将 Vue 代码编译成微信小程序、H5 等多端代码,一次开发多端运行,非常适合这种轻量级工具。

图片图片

四、核心代码实现:用 Vue3 写计时器

1. 基础数据与响应式

首先定义输入数据的响应式变量。Vue3 的 ref 可以创建响应式数据,computed 可以定义计算属性。

// 使用组合式 API,在 setup 函数中定义
import { ref, computed, onMounted, onUnmounted } from'vue';

exportdefault {
setup() {
    // 输入数据:月薪、每日工时、工作日数
    const monthlySalary = ref(null);
    const hoursPerDay = ref(null);
    const workDays = ref(null);

    // 计算时薪:月薪 / (工作日数 × 每日工时)
    const hourlyWage = computed(() => {
      if (!monthlySalary.value || !hoursPerDay.value || !workDays.value) return0;
      return (monthlySalary.value / (workDays.value * hoursPerDay.value)).toFixed(2);
    });

    return { monthlySalary, hoursPerDay, workDays, hourlyWage };
  }
};

这里有个细节:hourlyWage 用 computed 定义,意味着当输入数据变化时,它会自动重新计算,无需手动调用。这就是响应式的魅力。

2. 计时器的实现

计时器需要精确到秒,同时要避免内存泄漏(比如组件卸载时忘记清除定时器)。Vue3 的生命周期钩子 onMounted 和 onUnmounted 可以解决这个问题。

// 在 setup 函数中继续添加
const seconds = ref(0); // 已工作秒数
let timer = null; // 定时器引用

// 启动计时
const startTimer = () => {
if (timer) return; // 避免重复启动
  timer = setInterval(() => {
    seconds.value++;
  }, 1000);
};

// 停止计时(可选功能)
const stopTimer = () => {
if (timer) {
    clearInterval(timer);
    timer = null;
  }
};

// 组件卸载时清除定时器
onUnmounted(() => {
stopTimer();
});

// 计算已工作时间(格式化为 HH:MM:SS)
const formattedTime = computed(() => {
const hours = Math.floor(seconds.value / 3600);
const minutes = Math.floor((seconds.value % 3600) / 60);
const secs = seconds.value % 60;
return`${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
});

// 计算累计收益
const totalEarnings = computed(() => {
if (!hourlyWage.value) return'0.00';
const earnings = (seconds.value / 3600) * hourlyWage.value;
return earnings.toFixed(2);
});

这段代码的关键是:

  • • 用 ref 跟踪秒数,定时器每秒递增 seconds.value
  • • 用 computed 实时格式化时间和计算收益;
  • • 用 onUnmounted 清除定时器,避免组件卸载后定时器仍在运行(内存泄漏)。

3. 数据持久化:uni-app 的本地存储

为了让用户下次打开小程序时能自动读取上次的输入,需要用到 uni-app 的 uni.setStorageSync 和 uni.getStorageSync(类似浏览器的 localStorage)。

// 在 setup 函数中添加
// 加载缓存数据(组件挂载时)
onMounted(() => {
const savedData = uni.getStorageSync('clockConfig');
if (savedData) {
    monthlySalary.value = savedData.monthlySalary;
    hoursPerDay.value = savedData.hoursPerDay;
    workDays.value = savedData.workDays;
  }
});

// 保存数据(输入变化时)
constsaveConfig = () => {
if (monthlySalary.value && hoursPerDay.value && workDays.value) {
    uni.setStorageSync('clockConfig', {
      monthlySalary: monthlySalary.value,
      hoursPerDay: hoursPerDay.value,
      workDays: workDays.value
    });
  }
};

// 在模板中,输入框的 @change 事件绑定 saveConfig

这样,用户每次修改输入后,数据会自动保存到本地;下次打开小程序时,会自动加载。

五、小程序集成:从 Vue 到小程序的适配

uni-app 的优势在于“一次编码,多端运行”,但小程序有一些特有的限制,需要注意:

  1. 1. 样式隔离:小程序的 wxss 不支持 scoped(类似 Vue 的样式作用域),但 uni-app 会自动处理,只需在 Vue 组件中使用 <style scoped> 即可。
  2. 2. API 替换:uni-app 封装了跨平台 API(如 uni.navigateTo),编译到小程序时会自动转换为 wx.navigateTo,无需额外处理。
  3. 3. 性能优化:小程序对页面渲染性能要求较高,计时器的更新要避免频繁触发重绘。这里用 ref 而非 reactive,因为 ref 对基本类型(如数字)的响应式更高效。

六、上线后的反馈:工具的价值超出预期

上线一周,用户的反馈让我很意外:

  • • 有自由职业者用它记录项目耗时,计算每单实际收益;
  • • 有学生用它监督兼职时长,避免被克扣工资;
  • • 甚至有宝妈用它记录“带娃工时”,调侃自己是“月薪 3 万的家庭 CEO”。

最让我开心的是一条评论:“现在每次打开手机,看到计时页面的数字跳动,居然有点舍不得摸鱼了。”——这正是我做这个工具的初衷:让时间的价值变得具体,让努力被“看见”

七、结语:技术的意义在于解决具体问题

这个项目很小,代码量不到 500 行,但它让我再次相信:技术的价值,不在于有多复杂,而在于能否解决具体的、真实的问题。

如果你也想体验「牛马时钟」,可以扫码进入小程序。如果有建议或 bug,欢迎在评论区留言——下一个版本,可能就有你的需求!