用AI七天开发出一个上班族摸鱼实用小工具!

0 阅读14分钟

我把月薪除以 22 天再除以 8 小时,做了个小程序:让工资在屏幕上自己跳

起因很朴素:我想知道,我此刻坐在这儿,每一秒到底在赚多少钱。 结果这个小程序写完之后,我发现自己上班最大的乐趣,变成了盯着屏幕上的数字跳。

🤔 起因:一个所有打工人都想过的问题

某天下午三点,我在工位上发着呆,突然冒出一个念头:

"我现在敲这一行代码,值多少钱?"

就这?对,就这。

你别笑,这个问题一旦在心里扎根,就拔不掉了。因为它背后藏着一个极其残忍的算术:

时薪 = 月薪 ÷ 月工作天数 ÷ 每日工作时长

一万块的月薪,22 个工作日,每天 8 小时——一小时 56 块 8,一分钟 9 毛 5,一秒钟 1 分 6。

那一刻我悟了:我上班的时候,每一秒都在掉钱,只是没人告诉我。

市面上没有工具告诉你这件事。工资条一个月来一次,还是滞后一个月发的。于是我做了一个小程序,把"我此刻赚了多少"变成屏幕上滚动的数字。

它叫 「薪安手账」,slogan 是:让你的每分秒劳动都有价格。

💰 第一屏:钱在跳

打开首页,最显眼的就是这一行数字:

今日已赚 ¥127.43 —— 而且它真的在跳,每秒一跳。

数字下面挂着两个倒计时:

主倒计时副倒计时什么时候切
距离下班还有 04:23:11距离上班还有 17:36:49上班中 / 午休中
距离上班还有 15:02:33距离下班还有 00:00:00未上班 / 已下班 / 休息日

这俩是自动切换的。 早上九点前你看到的是"离上班还有 X",九点一过它自己翻成"离下班还有 X",不是两个死数字。进度条也会跟着走——上午十点你看到的是 12%,下午五点四十看到的是 92%,那感觉比任何 KPI 都直观。

再往下还有一句更扎心的:

距离下班,你还能再赚 ¥88.20

这行数字治好过我的摸鱼愧疚症。 因为它把"摸鱼"这件事从一个模糊的道德问题,变成了一个精确的经济问题:你摸鱼一小时,损失的是 56.8 元,而不是"一段时间"。

📐 第二步:把工资算对(这才是真正的难点)

一开始我以为这就是个除法。写完才发现, "上班赚多少钱"这件事,比想象中脏得多。

通用的除法公式不管用

真实情况是这样的:

  1. 中午吃饭那一小时不算钱。 但 12:30 的时候你已经在吃饭了,那这一小时的钱是从哪一刻开始扣的?
  2. 加班了,下班时间往后延了。 原本 18:00 收工,加班到 21:00,那 18:00 以后的每一秒,都是在"付额外的钱"。
  3. 请假了。 今天请了半天假,那这半天不能算收入,还得从今日已赚里减掉。
  4. 大小周。 这一周周六上班,下周不上。你怎么知道今天是"该上班的周六"还是"不该上班的周六"?

这四条,每一条都能把一个干净的公式变成一团泥。

大小周:我选择用 ISO 周数兜底

大小周是最恶心的一个。因为"大周还是小周"这件事,理论上你永远要去问 HR。

我的做法是:给一个默认答案,同时允许你手动覆盖。

// 判断指定日期周六是否上班(大小周规则)
function isSaturdayWorkDay(config, now) {
  if (config.workSystem !== '大小周') return true
  if (now.getDay() !== 6) return true
​
  // 优先使用用户在设置中手动选择的 isBigWeek
  if (typeof config.isBigWeek === 'boolean') {
    return !config.isBigWeek  // 大周=双休→周六休息;小周=单休→周六上班
  }
​
  // 兜底:ISO 周数交替(奇数周=大周=双休,偶数周=小周=单休)
  const weekNum = getISOWeekNumber(now)
  return weekNum % 2 === 0
}

这样你打开小程序就有答案,猜错了在设置里点一下"这周大周/小周"就行。先能跑,再准。 如果反过来——先做一堆配置界面才让你用——那用户第一屏就流失了。

午休扣钱的三种状态

午休最难写。因为时间落在三个不同的区间,算法完全不同:

if (nowMinutes >= start && nowMinutes <= end) {
  workSeconds = (nowMinutes - start) * 60 + nowSeconds
​
  if (config.hasLunchBreak) {
    const lunchStart = parseTime(config.lunchStart)
    const lunchEnd = parseTime(config.lunchEnd)
    if (nowMinutes >= lunchEnd) {
      workSeconds -= (lunchEnd - lunchStart) * 60      // 午休已结束:整段扣掉
    } else if (nowMinutes >= lunchStart) {
      workSeconds -= (nowMinutes - lunchStart) * 60 + nowSeconds  // 午休中:扣到此刻
    }
  }
}

第三种情况——正在午休——最容易漏。如果只写"过了午休就扣一小时",那 12:30 时你的屏幕还在涨钱,用户第一眼就发现你在骗他。

这种地方不需要 AI 有多聪明,只需要你足够抠。

⚡ 第三步:每秒 setData 三次,手机差点被我玩炸

这是整个项目里最有教育意义的坑。

第一版首页长这样:

// ❌ 第一版
this.setData({ nowTime: ... })        // 每秒
this.setData({ todayEarnedStr: ... }) // 每秒
this.setData({ countdown: ... })      // 每秒

看着没毛病。跑起来也没毛病。但它的代价是:数据每秒穿越 3 次 JSBridge。

小程序里 setData 是逻辑层到渲染层的序列化 + 通信,不是本地变量赋值。每秒 3 次,加上字段几十个,低端安卓机上页面开始发烫——你做了个"帮你摸鱼"的工具,结果它自己在疯狂费电。

改成一次:

// ✅ 优化后:每秒只穿 1 次
updateAll() {
  const todayResult = calcToday(config, hourlyWage, now)
  const monthStats = calcMonthStats(config, hourlyWage, now, todayResult, firstUseDate)
  const dual = calcDualCountdown(config, now, totalOvertimeSeconds)
  const { leaveSeconds, leaveEarned } = this._loadTodayLeave(now)
​
  this.setData({
    nowTime: `${h}:${m}:${s}`,
    workStatus,
    workSeconds: Math.max(0, todayResult.workSeconds + elapsedOvertimeSeconds - leaveSeconds),
    todayEarnedStr: Math.max(0, todayResult.earned + overtimeEarned - leaveEarned).toFixed(2),
    monthEarnedStr: ...,
    primaryCD, secondaryCD, primaryLabel, secondaryLabel,
    countdownProgress: dual.progress,
    todayOvertimeEarned: overtimeEarned,
    todayLeaveEarned: leaveEarned,
  })
}

18 个字段,一次通信。

顺手还做了两件事:

  • 把所有计算抽成纯函数放进 utils/salary.js(552 行),不碰 setData、不产生副作用,输入配置和时间,输出一个完整结果对象。首页、统计页、摸鱼页三份重复了 600 多行的计算逻辑,从此只剩一份。
  • 统计页从 1 秒刷新改成 5 秒。统计页的数据变化是"分"级的,每秒刷新纯粹是拿电量换心理安慰。

性能优化这件事,通常不是"加",而是"减"。减掉那些你以为很有必要、但其实没人在意的刷新。

🎮 第四步:摸鱼页,把玩梗做成可交互的东西

如果这个小程序只有工资在跳,它三天就会被卸掉。所以我给它加了一个"摸鱼页"——12 个玩法,做成九宫格,长按可以拖动排序。

💰 记账        🐟 开始摸鱼    🚽 带薪如厕    🧻 如厕成绩
🔮 摸鱼运势    👀 消灭老板    💬 摸鱼群聊    🎫 摸鱼假条
🏆 摸鱼成就    🖼 摸鱼壁纸    🏀 摸鱼投篮    🎣 摸鱼钓鱼

挑三个我最得意的讲讲。

摸鱼运势:44 条签

每天可以抽 5 次。签分六等:大吉 / 吉 / 中吉 / 小吉 / 末吉 / 凶,每条都有"宜 / 忌 / 一句话点评":

{
  type: 'daji', label: '大吉', emoji: '🎉', color: '#ff6b35',
  yi: '去茶水间接水(顺便逛一圈)',
  ji: '无',
  desc: '今天摸鱼如鱼得水,老板绝对发现不了!'
}

还有这种:

  • 宜:老板外出开会,全天自由日 / 忌:坐老板工位摸鱼
  • 宜:带薪蹲坑五分钟,思考人生 / 忌:蹲太久腿麻走不回来
  • 宜:假装在写周报,实则在写周末计划 / 忌:被同事看到你在搜旅游攻略

44 条,全是我自己编的。 这部分的 ROI 高到离谱——代码量不到 200 行,但它是分享率最高的功能,因为每个人抽到"凶"都会截图发给同事。

摸鱼假条:用 Canvas 画一张"带薪摸鱼许可证"

点一下就生成一张票:编号、持票人、事由、时长、审批人、金额、签发时间、到期时间。

金额怎么来的?时薪 × 分钟数。抽到 30 分钟的假条,它就真的帮你算出来"这一张值 28.40 元"。

整张票没有任何图片素材,纯 Canvas 2D 画的:渐变底、白色圆角票面、虚线分割、随机事由、随机审批人。核心是这几个手写的工具函数:

const roundRect = (x, y, w, h, r) => {
  ctx.beginPath()
  ctx.moveTo(x + r, y)
  ctx.lineTo(x + w - r, y)
  ctx.arcTo(x + w, y, x + w, y + r, r)
  ctx.lineTo(x + w, y + h - r)
  ctx.arcTo(x + w, y + h, x + w - r, y + h, r)
  ctx.lineTo(x + r, y + h)
  ctx.arcTo(x, y + h, x, y + h - r, r)
  ctx.lineTo(x, y + r)
  ctx.arcTo(x, y, x + r, y, r)
  ctx.closePath()
}
​
const dashLine = (x1, y, x2) => {
  ctx.save()
  ctx.setLineDash([4, 4])
  ctx.strokeStyle = 'rgba(0,0,0,0.16)'
  ctx.lineWidth = 0.5
  ctx.beginPath(); ctx.moveTo(x1, y); ctx.lineTo(x2, y); ctx.stroke()
  ctx.restore()
}

生成完可以保存到相册。这是我预埋的传播按钮——一张能发朋友圈的假条,比任何"分享得积分"都有效。

摸鱼投篮:60 秒,会移动的篮筐,还带风

Canvas 2D 写的物理小游戏。篮筐左右移动,随机变速,还会抽 buff:

🍃 顺风   —— 球会朝篮筐方向轻微加速
💨 逆风   —— 按固定风向吹偏
✨ 双倍得分

碰撞是硬算的:篮圈两个端点当圆、篮板当矩形,法线反弹 + 切向摩擦。

// 碰篮圈端点:法线反弹
const dx = ball.x - endX, dy = ball.y - endY
const d = Math.sqrt(dx*dx + dy*dy)
if (d > 0 && d < grip) {
  ball.x = endX + (dx/d) * grip
  ball.y = endY + (dy/d) * grip
  this._bounceBall(ball, dx/d, dy/d)   // normalDamp 0.70 / tangDamp 0.92
}

但这段代码里最关键的不是物理,是这两行:

_basketLoop(ts) {
  // dt 必须 clamp:从后台切回来时两帧间隔可能上万毫秒,不限制会让球一帧飞出界
  const dt = b.lastTs ? Math.min(ts - b.lastTs, 32) : 16.7
  b.lastTs = ts
  const k = dt / 16.7
  ...
  // 倒计时仅在整秒变化时 setData(绝不在 rAF 里逐帧 setData)
  const sec = Math.ceil(left)
  if (sec !== this.data.basket.timeLeft) {
    this.setData({ 'basket.timeLeft': sec })
  }
}

两个坑,都踩过:

  1. requestAnimationFrame 里绝不能逐帧 setData。 60 帧 / 秒 = 60 次通信 / 秒,直接卡死。倒计时的秒数只在"整秒变了"的时候才 setData,其余全部在 Canvas 里画。
  2. 切后台回来,两帧间隔可能是几万毫秒。 不 clamp,球会以一个荒谬的速度一帧飞出屏幕——用户看到的是"我的球凭空消失了"。Math.min(dt, 32) 一行解决。

🛡️ 第五步:那些没人看、但必须有的一层

背景图三级降级链

用户能自定义主题背景(上传自己的照片)。这里有个真实问题:云存储的 fileID 每次渲染都要下载,会很慢;本地缓存又可能坏掉。

所以渲染源是一条链,失败就往下退:

本地持久文件 → 运行期临时文件 → 云 fileID → 放弃(不再改)
function _decideNextSrc(curSrc) {
  const fileID = getBgImage()
  if (!fileID) return ''
  const local = getBgLocalPath(fileID)
  if (curSrc && local && curSrc === local) {
    dropBgLocalCache(fileID)                  // 持久文件不可用 → 丢掉它
    const tmp = getBgSessionTemp(fileID)
    if (tmp && tmp !== curSrc) return tmp
    return fileID
  }
  if (curSrc && curSrc === getBgSessionTemp(fileID)) {
    clearBgSessionTemp()                      // 临时文件也不可用 → 清掉
    return fileID
  }
  if (curSrc !== fileID) return fileID
  return ''                                   // 云渲染也失败 → 不再改(防止无限循环)
}

最后那个 return '' 是重点。降级链最危险的地方不是降级失败,而是失败后无限重试。 onBgImageError 一旦触发就换源,如果换的来源还是坏的,就会死循环刷日志、刷请求。所以链条必须有终点。

八个集合,自动建

数据全在云开发。一共有 8 个集合:

user_config  overtime_records  leave_records  daily_records
fish_data    ledger_records    user_first_use  wallpapers

问题是:新环境部署的时候,任何一个集合没建,页面就白屏报 collection not exists。 手动去控制台点 8 次?不可能记得住。

所以有个 initDatabase 云函数,一次把集合建齐,且对"已存在"做了幂等处理:

try {
  await db.createCollection(name)
  detail[name] = '已新建'
} catch (err) {
  const msg = (err && (err.errMsg || err.message)) || ''
  detail[name] = /already exist/i.test(msg) ? '已存在' : '已存在或跳过'
}

云函数以管理员身份运行,所以它能建集合,客户端不行。这类"运维脚本"也值得写成云函数——它会在你换环境、加新同事、重建测试库的时候救你三次。

🏆 第六步:给摸鱼一个"身份"

数据攒下来之后,最爽的不是数字,是称号。

九级头衔,按"累计摸鱼金额 + 累计天数"双重判定:

😶 未入门 → 🐣 摸鱼学徒 → 🥉 青铜摸鱼人 → 🥈 白银摸鱼人
→ 🥇 黄金摸鱼人 → 💎 铂金摸鱼人 → 👑 钻石摸鱼人 → 🐉 摸鱼宗师 → 🏆 带薪王者

最高一级"带薪王者"的门槛是:累计摸鱼折算 ¥50000、累计 365 天。

规则上还做了一个小心思——前四级(学徒到白银)金额或天数满足一个就升,后面五级必须两个都满足:

const qualified = t.id <= 3 ? (metMoney || metDays) : (metMoney && metDays)

原因很简单:新人需要快速升级获得反馈,老玩家需要难度来维持目标感。 一套规则同时服务两种人,就是靠分段。

配合称号还有两样东西:

  1. 本月摸鱼战报——一张可以分享的月度报表:摸鱼几小时、折合多少钱、换算成几杯咖啡几顿饭几场电影、带薪如厕几次。
  2. 成就页——累计天数、连续摸鱼天数、单次最长记录、本周 vs 上周对比。

🎨 第七步:六套主题 + 一个自定义调色板

六套主题:清新绿 🌿 / 暗夜黑 🌙 / 海洋蓝 / 暖橙色 / 紫罗兰 / 樱花粉。

每套主题不只是一个主色,而是一整套 token:背景渐变起止色、导航栏、主色、亮色、三级文字色、卡片背景、卡片描边、按钮背景/文字、tab 选中色。

因为整站 UI 都是吃变量的,切主题是"换一套 CSS 变量",不是"改一堆样式"。

后来还加了自定义调色板——用户自己选渐变的起止色,实时预览。这样每台手机打开这个小程序,长得都不太一样。

🔢 一些数字

项目数字
代码总量17,527 行(JS 9,807 / WXSS 4,785 / WXML 2,130)
页面7 个(欢迎 / 引导设置 / 首页 / 统计 / 摸鱼 / 设置 / 月报)
摸鱼玩法12 个
运势签44 条
成就称号9 级
主题6 套 + 自定义调色板 + 自定义壁纸
云函数11 个
云数据库集合8 个
图片素材17 个(全是图标,UI 全靠 CSS 和 Canvas)
开发跨度2026 年 3 月 → 9 月,22 次提交

对,整个小程序没有一张 UI 背景图。 所有渐变、卡片、圆角、阴影、票面、篮球、鱼——全部是样式和 Canvas 画的。

💬 关于和 AI 协作,几句实话说

这个项目的代码,绝大部分是和 AI 一起写的。但半年下来我最深的体会是:

AI 能把"想清楚的事"写得飞快,但想不清楚的事,它会帮你一起想歪。

具体三条:

  1. 算钱的地方,别让 AI 一把梭。 午休扣款的三种状态、大小周的兜底、加班的时薪分段——这些是业务规则,不是代码技巧。AI 写出来的第一版一定"看起来很对",但边界全错。必须自己拿时间点一个个核。
  2. 平台特性 AI 最容易翻车。 云函数权限、db.createCollection 的幂等、onBgImageError 的重试保护、requestAnimationFrame 里的 setData——这些不是"知识",是"坑"。文档里写着,但不会主动提醒你。
  3. AI 最擅长的是"把判断变成参数"。 我说"篮筐要越来越难",它就给出 base 速度 + jitter 随机抖动 + 分阶段阈值这一套可调参数。这种地方它比我快十倍。

还有一条:先能跑,再准确。 大小周先给个默认答案,用户错了自己改;而不是要求用户先填 20 项配置才能看到第一屏数字。这条规则救了我无数次。

🎯 最后

小程序叫 「薪安手账」,微信里搜名字就能找到,或扫下方小程序码。

核心就一句话:

你的每一秒,都有价格。

打开它,你会看到今天的钱在一分一分地涨;下班前你会看到"距离下班还能再赚 ¥88.20";摸鱼的时候你能抽一支签、开一张带薪假条、打 60 秒篮球、或者干脆盯着自己的摸鱼称号傻笑。

它不帮你涨薪,也不帮你跳槽。

它只是把你已经在付出的时间,换算成一个你一直想知道、但从来没人告诉你的数字。

微信图片_20260920095424_12624_43.png

(本文所有数据、代码均为真实项目内容。如果你觉得这篇有点意思,点个赞;如果觉得我说的"打工人算钱学"有点道理,去小程序里给自己算一算。)