我把月薪除以 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 元,而不是"一段时间"。
📐 第二步:把工资算对(这才是真正的难点)
一开始我以为这就是个除法。写完才发现, "上班赚多少钱"这件事,比想象中脏得多。
通用的除法公式不管用
真实情况是这样的:
- 中午吃饭那一小时不算钱。 但 12:30 的时候你已经在吃饭了,那这一小时的钱是从哪一刻开始扣的?
- 加班了,下班时间往后延了。 原本 18:00 收工,加班到 21:00,那 18:00 以后的每一秒,都是在"付额外的钱"。
- 请假了。 今天请了半天假,那这半天不能算收入,还得从今日已赚里减掉。
- 大小周。 这一周周六上班,下周不上。你怎么知道今天是"该上班的周六"还是"不该上班的周六"?
这四条,每一条都能把一个干净的公式变成一团泥。
大小周:我选择用 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 })
}
}
两个坑,都踩过:
requestAnimationFrame里绝不能逐帧 setData。 60 帧 / 秒 = 60 次通信 / 秒,直接卡死。倒计时的秒数只在"整秒变了"的时候才 setData,其余全部在 Canvas 里画。- 切后台回来,两帧间隔可能是几万毫秒。 不 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)
原因很简单:新人需要快速升级获得反馈,老玩家需要难度来维持目标感。 一套规则同时服务两种人,就是靠分段。
配合称号还有两样东西:
- 本月摸鱼战报——一张可以分享的月度报表:摸鱼几小时、折合多少钱、换算成几杯咖啡几顿饭几场电影、带薪如厕几次。
- 成就页——累计天数、连续摸鱼天数、单次最长记录、本周 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 能把"想清楚的事"写得飞快,但想不清楚的事,它会帮你一起想歪。
具体三条:
- 算钱的地方,别让 AI 一把梭。 午休扣款的三种状态、大小周的兜底、加班的时薪分段——这些是业务规则,不是代码技巧。AI 写出来的第一版一定"看起来很对",但边界全错。必须自己拿时间点一个个核。
- 平台特性 AI 最容易翻车。 云函数权限、
db.createCollection的幂等、onBgImageError的重试保护、requestAnimationFrame里的 setData——这些不是"知识",是"坑"。文档里写着,但不会主动提醒你。 - AI 最擅长的是"把判断变成参数"。 我说"篮筐要越来越难",它就给出
base速度 +jitter随机抖动 + 分阶段阈值这一套可调参数。这种地方它比我快十倍。
还有一条:先能跑,再准确。 大小周先给个默认答案,用户错了自己改;而不是要求用户先填 20 项配置才能看到第一屏数字。这条规则救了我无数次。
🎯 最后
小程序叫 「薪安手账」,微信里搜名字就能找到,或扫下方小程序码。
核心就一句话:
你的每一秒,都有价格。
打开它,你会看到今天的钱在一分一分地涨;下班前你会看到"距离下班还能再赚 ¥88.20";摸鱼的时候你能抽一支签、开一张带薪假条、打 60 秒篮球、或者干脆盯着自己的摸鱼称号傻笑。
它不帮你涨薪,也不帮你跳槽。
它只是把你已经在付出的时间,换算成一个你一直想知道、但从来没人告诉你的数字。
(本文所有数据、代码均为真实项目内容。如果你觉得这篇有点意思,点个赞;如果觉得我说的"打工人算钱学"有点道理,去小程序里给自己算一算。)