一、前言导读:除了"我想做",其他都是空白
在做这个项目时,我心里只有一个想法:
我必须每天面对电脑,干眼越来越严重,只能靠眼药水和热敷进行辅助缓解。但忙起来我总会忘记。通过手机定闹钟我又觉得太麻烦了------掏手机、解锁、点掉提醒、回头继续写代码,整个流程太重。所以我决定开发一款适合我的护眼提醒工具。
就这一句。后面所有的 proposal / design / specs / tasks 都是从这一句开始聊出来的。
没有界面草图,没有功能清单,没有技术选型,没有架构图。
如果那天 AI 让我「先写代码」,结局大概是这样的:
- AI 给我一个 Electron + React + Material UI 的庞然大物,5MB 装包变 80MB
- 主界面塞满色温滑块、亮度调节、休息时长、休息音乐
- 装上第一次启动,三个提醒一齐弹,我立刻卸载它自己写的工具
「想做个产品」与「产品从 0 到 1」之间的鸿沟,技术从来不是问题,思考才是。
这篇文章我想讲一件具体的事:我怎么用 OpenSpec(spec-driven 工作流)从一个模糊想法聊到 v0.1 发布,中间经历过哪些真实决策。
二、最开始的 1 小时:脑子里到底有多空白
回放一下我打开项目时的真实状态。
2.1 界面:完全没概念
我想要的 UI 是什么?
- 要几个窗口?不知道
- 弹窗长什么样?不知道
- 主界面要不要显示今日数据?不知道
- 配色?不知道
我唯一确定的是「装上就忘」这四个字。意味着 UI 存在感要低。但这只是个形容词,不是设计方案。
2.2 核心功能:想到啥算啥
我想要的功能是什么?一锅粥。
- 20-20-20 提醒(听说过,但眼睛已经干,预防不解决当下问题)
- 滴眼药水提醒(自己需要,每天 1-2 次,忙起来会忘)
- 热敷提醒(自己需要,每天至少 1 次,同样会忘)
- 屏幕使用时长(听说护眼软件都有)
- 色温调节(听说过 f.lux)
- 蓝光过滤(听说过)
- 休息音乐 / 白噪声(听说过)
问题是我自己也没分清:市面软件大多在帮眼睛「少用」,但我需要的是「别忘了用」------滴眼药水、热敷,这些治疗动作到了时间必须有人提醒我执行。到底哪些是真需求,哪些是市面软件让我以为我需要?我分不清。
2.3 技术架构:连问题都不会问
我应该用什么架构?问不出来。
- 单进程?多进程?
- 提醒调度跑在前端还是后端?
- 屏幕状态怎么感知?
- 状态保存在哪?
- 多窗口之间怎么通信?
最可怕的是,我连「应该问什么问题」都不知道。
2.4 技术栈:随大流
我应该用什么技术栈?没研究过。
Electron?Tauri?Flutter?Wails?Qt?这些名字我能背出来,但每个名字背后是什么生态,我一无所知。
如果你和我当时一样,这篇文章就是为你写的。
三、解法:OpenSpec 是 spec-driven 工作流,我负责回答和审阅
OpenSpec 在我眼里就是一套 spec-driven 工作流 :模板 + 命令 + AI 协作。默认提供 4 个核心命令(explore / propose / apply / archive)。其中 opsx:explore 走对话(它问,我答),其他 3 个 命令(propose/apply/archive)走文档与任务管理。
| 角色 | 谁 | 干什么 |
|---|---|---|
| 工作流工具 | OpenSpec | 走对话(explore)+ 模板填充(propose)+ 任务追踪(apply)+ 归档(archive)等命令协作 |
| 需求方(我) | 我 | 回答问题,把需求和范围聊明白 |
| 审阅人(我) | 我 | 打开 4 个文件,确认内容和我理解的一致 |
| 代码执行者 | AI | 规格审阅通过后,按 task 顺序写实现 |
这篇文章我重点讲我自己的如何使用OpenSpec :回答它的提问、审阅它生成的文件。
我实际跑的 OpenSpec 顺序是这样的(4 个核心命令):
markdown
1. opsx:explore ── 探索模式,OpenSpec 反问我 8 个问题,我把回答整理成思路
2. opsx:propose ── 一条命令同时生成 proposal + design + specs + tasks 4 个文件
(仓库里看到的 openspec/changes/archive/2026-07-20-add-mumu-eye-care/ 就是这次产出的归档)
3. opsx:apply ── 4 个文件都审过之后,AI 按 task 顺序写代码
4. opsx:archive ── 实施完成后归档,保留审计轨迹
下面我按这个顺序讲。每一步展示「我原来在想什么 → OpenSpec 问了我什么 → 我回答了什么 → 它生成了什么文件 → 我怎么审阅」。
四、Step 1:探索模式------「我到底想要啥」
4.1 我输入的 prompt
bash
opsx:explore "我想做一款护眼工具"
4.2 AI 反过来问我的 8 个问题
OpenSpec 的 explore 模式不是直接给我方案,是反问。我把回答贴出来,因为我后来发现这 8 个问题就是后面所有文档的事实基础。
- 你的目标用户是谁?我自己,干眼患者。这决定了「专业术语不能用」。
- 它要解决的核心问题是什么?我需要的是每天按时滴眼药水(1-2 次)、按时热敷(至少 1 次),把治疗动作执行到位。
- 哪些功能是必须的,哪些是 nice-to-have?20-20-20 + 滴眼药水必须,色温 nice-to-have。
- 你希望它的「存在感」是什么?存在感低,33MB 内存、70ms 启动。
- 触发场景是主动还是被动?被动提醒,绝不打断心流。
- 用户拒绝提醒的代价是什么?高。所以必须可一键暂停 30 分钟。
- 数据需要同步吗?不联网,所有数据本地。
- 失败时降级到哪?Win32 API 调用失败就退化为「按时间提醒」,别让功能挂。
4.3 我回答完后,OpenSpec 输出什么
一份结构化的探索纪要,把所有回答汇总:
用户:干眼患者,每天 10h+ 电脑工作
核心:从「我忘了」变为「工具记得」
存在:低,常驻后台无感
必选:20-20-20 + 滴眼药水 + 热敷
可选:色温调节(v0.2 再做)
触发:被动提醒,绝不主动打断
暂停:一键 30 分钟 / 1 小时 / 到明早
数据:本地 SQLite,不联网
降级:Win32 API 失败 → 退化为纯时间提醒
这段思考纪要就是后续所有文档的事实基础。没有它,proposal 就是凭空捏造。
五、Step 2:proposal.md------「为什么做、做什么、影响什么」
5.1 proposal.md 是什么 + 我怎么审阅它
proposal.md 是 OpenSpec 在我回答完探索问题后,整理出的第一份正式文件。它有 4 段:
| 段 | 我需要确认它写的是不是我心里想的 |
|---|---|
| Why | 它把我回答的"痛苦"总结成一段话了吗? |
| What Changes | 它把我列的功能改写成了动词开头的清单吗? |
| Impact | 它把"做什么 / 不做什么 / 影响谁 / 用什么依赖"都列清了吗? |
| Capabilities | 它把功能拆成了 reminders / statistics / ui / settings 4 个独立能力吗? |
5.2 opsx:propose 生成的 proposal.md(节选自仓库)
markdown
## Why
我是一名干眼症患者,每天对着电脑 10 小时以上。
我尝试过几款护眼软件,总有某个点让我想卸------弹窗太勤、关不掉、功能堆得太杂。
手机定闹钟又太重------掏手机、解锁、点掉提醒、回头继续写代码,整个流程太麻烦。
更让我难受的是:我经常忘记滴眼药水、忘记热敷眼罩。
市面软件大多在帮眼睛「少用」,但我需要的是「别忘了用」------按时把治疗动作执行到位。
## What Changes
- 新增 定时提醒 能力:20-20-20 法则默认每 20 分钟提醒一次
- 新增 眼药水/热敷提醒 能力:默认每 2 小时提醒一次眼药水、中午 13:00 提醒热敷
- 新增 屏幕使用时长统计 能力:算法是「亮屏时长 − 锁屏时长 − 30 分钟无操作」
- 新增 UI 界面 能力:极简单列主界面、右下角紧凑弹窗、动态眼睛托盘图标
## Impact
- 目标用户:干眼患者 / 长时间电脑工作者
- 新增依赖:前端 React 18+TS+Tailwind+Zustand+Radix;后端 Rust 1.78+ + Tauri 2 + tokio + rusqlite
- 运行时占用:常驻内存 < 20MB
- 不影响:不联网、不上传任何数据、不监听输入内容、不使用摄像头
5.3 我审阅 proposal 时关心的几件事
| 我原来空白 | proposal 里我要重点确认的 |
|---|---|
| 做哪些功能 | What Changes 列的能力清单,是不是我刚才说的那些 |
| 这功能值不值得做 | Why 段是不是从我的痛苦出发,没把"听起来很酷"的功能塞进来 |
| 做出来用户敢用吗 | Impact 段有没有写明「不影响什么」,建立信任的边界 |
| 模块怎么拆 | Capabilities 段拆的是不是合理,能独立交付 |
如果 proposal 有任何一段写偏了,我会回去改我的回答,让 OpenSpec 重新生成,直到 4 段都和我理解的一致。
六、Step 3:design.md------「界面到底长啥样」
6.1 我原来空白到什么程度
打开 design.md 模板前,我脑子里只有:
- 主界面「应该有个大数字显示今日时长」
- 弹窗「应该有个倒计时」
- 配色「应该温柔一点」
没了。
6.2 design.md 12 节:我审阅的内容
design.md 不是让你「画图」,是让你定规矩。沐目真实的设计规范(design.md)有 12 节:
- 设计理念:温暖治愈 + 极致克制
- 配色方案:米白 #FAF8F5 + 薄荷绿 #87A878 + 警示橙 #C8956D
- 字体规范:微软雅黑 / 苹方 / Noto Sans CJK SC
- 间距与圆角:4px 基础单位 / 圆角 4/8/12px
- 动效规范:入场 300ms / 退场 500ms
- 组件规范:滑块 / 单选 / 复选 / 按钮
- 弹窗详细规范:320×200 / 距离边缘 24px / z-index 1000
- 托盘图标规范:睁眼 / 闭眼
- 主界面规范:480×360 / 巨号数字 64px
- 设置窗口规范:800×600
- 文案规范:不用「您」、不用「色温 4500K」这种术语
- 待补充:V2+
6.3 一处 design 如何落到 Tailwind 配置
design.md 第二节写明主强调色是 #87A878(薄荷绿),警示色是 #C8956D(暖橙)。这段落到 mumu/tailwind.config.js:
js
module.exports = {
theme: {
extend: {
colors: {
primary: '#87A878', // 薄荷绿 主强调色
warning: '#C8956D', // 暖橙 警示色
bg: '#FAF8F5', // 浅色模式主背景 米白
'bg-card': '#FFFFFF', // 卡片背景 纯白
text: '#2C2825', // 主文字 深棕近黑
'text-sub': '#8B8378', // 副文字 灰棕
},
},
},
};
色号到 className 的转换就这么简单。
6.4 我审阅 design.md 时关心的几件事
| 我原来的疑问 | design.md 里我重点确认的 |
|---|---|
| 主色用什么 | 配色方案那一节,米白 + 薄荷绿是不是我想要的 |
| 弹窗多大 | 弹窗详细规范那一节,320×200、巨号 64px 是不是合适 |
| 倒计时动画多久 | 动效规范那一节,300ms / 500ms 节奏对不对 |
| 滑块最小值 | 组件规范那一节,15 分钟起是不是合理 |
| 文案怎么说 | 文案规范那一节,不用「您」、不用术语是不是够温柔 |
design.md 还要包含「不要做」。我那段写的是:
- ❌ 不做呼吸动画(用户已确认)
- ❌ 不做弹跳动画(幼稚感)
- ❌ 不做粒子特效(干扰感)
明确边界比列举功能更重要。AI 看到「不做」就不会越界。
七、Step 4:specs/*.md------「提醒什么、多久、什么场景」
7.1 我原来空白到什么程度
我想做「提醒」,但:
- 提醒什么?只知道 20-20-20
- 多久提醒一次?默认 20 分钟,但能改吗?
- 工作时段外要不要提醒?不知道
- 锁屏时怎么办?不知道
- 用户已经做了 5 次眼保健操,还要不要提醒?不知道
7.2 specs 14 个 Requirement:我审阅的内容
specs/reminders/spec.md 真实的 14 个 Requirement:
- Default reminder parameters:默认 09:00-18:00,间隔 20 分钟
- Configurable reminder parameters:间隔 15-60 分钟可调
- Reminder trigger conditions:6 个 AND 条件
- Reminder popup lifecycle:弹窗生命周期
- Countdown display:倒计时显示
- Fullscreen application handling:全屏应用不弹
- Lock screen handling:锁屏暂停 + 解锁续弹
- Shutdown and hibernation handling:关机不补弹
- Reminder pause:30 分钟 / 1 小时 / 到明早
- Rest count recording:计入每日统计
- Cross-period reminders:下班前 20 分钟静默
- Eye drop and warm compress reminders:眼药水 + 热敷
- Repeated eye drop dismissals:连续 3 次 dismiss 当日静默
- Wooden fish sound:木鱼音效规则
7.3 一条 Requirement 的标准写法
specs/*.md 不是散文,是有固定词法和结构的「行为合同」。我从 OpenSpec 那里学到的第一条规则:Requirement 必须用 SHALL/SHOULD/MAY 三个模态词,区分硬约束与软建议:
| 模态词 | 语义 | 用在什么场景 |
|---|---|---|
| SHALL | 必须做到 | 触发条件、边界处理、状态约束(强提醒必须在工作时段触发) |
| SHOULD | 建议做到 | 性能、可用性建议(UI 启动应 < 1s) |
| MAY | 可选做到 | 增强行为(弹窗出现时 MAY 播放木鱼音效) |
我用 SHOULD 而不是 SHALL 的地方都是「做不到不影响功能,只是体验差」。
第二条规则:Scenario 用 WHEN / THEN 写具体动作 + 可观察结果,因为 AI 看 WHEN/THEN 能直接生成测试用例。WHEN 是输入条件,THEN 是输出可断言。
仓库里 specs/reminders/spec.md 一条 Requirement 长这样:
markdown
### Requirement: Reminder trigger conditions
The system SHALL trigger a reminder only when ALL of the following are true:
current time is within configured work hours, time since last completed reminder
is at least the configured interval, user is not in a paused state, screen is on,
no fullscreen application active, and user is not locked.
#### Scenario: Lock during popup
- **WHEN** user locks the screen 10 seconds into a 20-second rest
- **THEN** the popup SHALL hide and the countdown SHALL pause
Scenario 例子(处理连续 dismiss 的边界):
markdown
### Requirement: Eye drop and warm compress reminders (soft prompts)
#### Scenario: Repeated eye drop dismissals
- **WHEN** user dismisses the eye drop prompt 3 consecutive times
- **THEN** no further eye drop reminders SHALL appear until the next workday
7.4 我审阅 specs 时关心的几件事
| 我原来的疑问 | specs 里我重点确认的 Requirement |
|---|---|
| 工作时段外要不要提醒 | Reminder trigger conditions 是不是把「不在工作时段 → 不提醒」写进去了 |
| 锁屏时弹窗怎么办 | Lock screen handling 是不是「隐藏 + 暂停 + 续弹」 |
| 关机后要不要补弹 | Shutdown and hibernation handling 是不是「不补,重启后按 last + interval 重算」 |
| 全屏 PPT 时弹不弹 | Fullscreen application handling 是不是「不弹」 |
| 用户连续关掉 3 次怎么办 | Repeated eye drop dismissals 是不是「当日静默」 |
| 下班前 20 分钟要不要静音 | Cross-period reminders 是不是「静音弹窗」 |
| 倒计时数字多大 | Countdown display 是不是 64px 粗体居中 |
7.5 我审阅 specs 的几条标准
我审 specs 时会按这 4 条标准对照:
- Requirement 必须用 SHALL/SHOULD/MAY 模态词,便于区分硬约束与软建议
- 每个 Requirement 至少 2 个 Scenario,正面 + 异常都要写
- WHEN / THEN 写具体数值。「20 秒」比「短时间」好,「3 次」比「几次」好
- 边界场景显式列出。「System muted」「Outside work hours」「Repeated dismissals」这类反例必须有
如果 specs 里某条 Requirement 没写边界,我会回去补我的回答,让 OpenSpec 重新生成。
八、Step 5:tasks.md------「架构怎么搭、技术栈怎么选」
8.1 我原来空白到什么程度
到了 tasks.md 这步,我面对的是:
- 4 个窗口(主 / 强提醒 / 软提示 / 设置)怎么协同?
- 提醒调度跑在 Rust 还是 React?
- 屏幕状态怎么监听?
- 设置存在哪?
- 用什么打包?
- 用什么框架?
这堆问题我连「应该问什么」都不知道。
8.2 tasks.md 是什么样的 + 我审阅什么
写 tasks.md 时,OpenSpec 模板会让我确认每个 task 写成下面这种结构:
markdown
### T07 - 实现提醒调度器
**步骤**:
- 创建 `src-tauri/src/reminders.rs`
- 订阅 T06 的 `mpsc::Receiver<StatisticsEvent>` + 调度 `mpsc::Sender<ReminderCommand>`
- 强提醒判定(pure):工作时段 + 距上次 ≥ 间隔 + ...
- 软提示判定(pure):...
**故意没做**:
- ⏸ 不在 `lib.rs::run()` 里 `.spawn(Scheduler)`:等 T08 托盘菜单一起接入
- ⏸ 强提醒倒计时驱动:MVP 简化为前端 JS setInterval
- ⏸ 重启不补弹:`last_strong_at` 不持久化,重启后按启动时间重算
**验收**:`cargo test --lib reminders` 全部 17 个测试通过;总计 39 个测试全绿
我审 tasks 时最在意「故意没做」这一栏。它帮我确认「这一版不做什么」。没想清楚这一栏的 task,我会补完之后让 OpenSpec 重新生成。
8.3 我从 tasks 里看出技术栈是怎么定的
我看完 tasks 后回头看,发现技术栈选型是被 tasks 一个一个推出来的,不是拍脑袋。
| task 暴露的需求 | 选型 |
|---|---|
| T05 要调 Win32 API | windows = "0.58" |
| T06 要 30s 轮询 + 状态机 | tokio = "1" |
| T04 要本地数据库存统计 | rusqlite = { version = "0.32", features = ["bundled"] } |
| T08 要系统托盘 → Tauri 2 已内置 | tauri = { features = ["tray-icon", "image-png"] } |
| T11 要 React 设置页 | react@19 + tailwindcss@3 + radix-ui |
| 状态要在多窗口同步 | zustand@4 |
| 客户端软件 + 存在感低 → 放弃 Electron | 选 Tauri(2.85MB vs 80MB) |
| 跨平台?→ MVP 只做 Windows(v0.2 再考虑 macOS) | #[cfg(windows)] 守卫 Win32 调用 |
这就是「为什么是 Tauri」的真实推导路径。不是看了一篇「Tauri vs Electron」对比表就拍板,是 tasks 一个一个需求列出来后,倒推出来的选型。
8.4 真实 tasks.md 的 24 个 T(22 个实施 + 2 个学习/环境)+ 依赖图
markdown
T00 ─► T00.5 ─► T01 ─┬─► T02 ─┬─► T13 ─┐
│ ├─► T14 ─┤
├─► T03 ──┤ ├─► T15 ─┬─► T17 ─┬─► T22
├─► T04 ──┤ │ ├─► T18 ─┤
├─► T05 ──┼─► T06 ─┤ └─► T19 ─┤
│ └─► T07 ──┤ ├─► T20 ─┘
├─► T08 ──┤ │ └─► T21 ─┘
│ ├─► T09 ──┤
│ ├─► T10 ──┼─► T10.5
│ └─► T11 ──┤
└─► T12 ──────────┘
│
└─► T16
6 个阶段、24 个 task(其中 T00 / T00.5 是学习 Rust + 跑通 Tauri Hello World 的准备 task,T01-T22 才是实施 task)、依赖图清晰、每个实施 task 都有「故意没做」。这张图本身就是架构图。
8.5 我审 tasks 时关心的几件事
| 我原来的疑问 | tasks 里我重点确认的 task |
|---|---|
| 提醒调度跑哪 | T07 是不是写在 Rust 后端、跑 tokio 任务 |
| 屏幕状态怎么监听 | T05 是不是调 Win32 API、30s 轮询 |
| 状态保存在哪 | T03 JSON 存设置 + T04 SQLite 存统计,路径 %APPDATA%\沐目\ 对不对 |
| 多窗口怎么通信 | T08/T10/T11 各窗口独立 entry + Tauri emit/invoke 桥 |
| 用什么打包 | T02 NSIS 配置对不对(中文、currentUser、uninstall hook) |
| 性能怎么保 | T18 性能验证:内存 < 50MB / CPU < 0.5% / 启动 < 1s |
如果 tasks 里有我没想过的事(比如某个 task 用了我不熟的库),我会回去跟 OpenSpec 聊清楚,再让它重新生成。
写完 tasks 后,AI 只看到「这一小步」,且每步都有验收。AI 不再发散,因为它有明确边界,而这个边界是我跟 OpenSpec 一条一条聊清楚的。
九、Step 6:opsx:apply------开始写代码
4 类文档(proposal / design / specs / tasks)都填完后,写代码反而是最简单的部分。
我让 AI 按 task 顺序写实现,规则是:
- 每个 task 的 pure 函数由 AI 写(should_trigger_strong、compute_color 等)
- 每个 task 的异步外壳由 AI 写,人 review(tokio::spawn / tauri::command)
- 每个 task 完成后跑验收命令
写代码的过程反而没什么好讲。该想的都在前面想清楚了。
下面挑 3 个最典型的实现(提醒调度、屏幕状态、托盘),展示 spec 到代码的逐字落地。
十、实操:spec 到代码的逐字落地
10.1 reminders/spec.md → reminders.rs(1558 行)
| spec Requirement | reminders.rs 实现位置 | 行数 |
|---|---|---|
Default reminder parameters |
default_settings in settings.rs |
36-55 |
Reminder trigger conditions |
should_trigger_strong |
319-368 |
Lock screen handling |
apply_pause + should_trigger_strong::ResumeActive |
322-334, 556-569 |
Reminder pause |
apply_manual_pause / apply_pause_until_tomorrow |
539-553 |
Rest count recording |
apply_complete_strong → db::increment_rest_count |
486-509 |
Cross-period reminders |
mute_sound = (end_secs - now_secs).abs() <= 20*60 |
363-365 |
Eye drop and warm compress reminders |
should_trigger_eye_drop / should_trigger_warm_compress |
382-464 |
Repeated eye drop dismissals |
SOFT_DISMISS_LIMIT = 3 + purge_old_dismissals |
38, 519-524 |
Wooden fish sound |
ReminderCommand::ShowStrongReminder { play_sound } |
50-56 |
10.2 真实代码:spec "Reminder trigger conditions" 落地
spec 里那条「trigger only when ALL of the following are true」有 6 个 AND 条件,AI 翻译成 Rust 时写成 5 个并列的早 return(active 续弹判定 + 工作时段 + 未暂停 + 距上次间隔 + 跨时段静默)。每个 return 都是 StrongDecision::NotTrigger,全过才走最后的 Trigger。
spec.md 写法(specs/reminders/spec.md:1-12):
markdown
The system SHALL trigger a reminder only when ALL of the following are true:
current time is within configured work hours, time since last completed reminder
is at least the configured interval, user is not in a paused state, screen is on,
no fullscreen application is active, and user is not locked.
落到 src-tauri/src/reminders.rs:319-368:
rust
pub fn should_trigger_strong(
state: &ReminderState,
settings: &Settings,
now: DateTime<Local>,
) -> StrongDecision {
// 续弹判定(spec: "Lock screen handling")
if let Some(active) = &state.active_strong {
if active.hidden {
return StrongDecision::ResumeActive(active.clone());
}
return StrongDecision::NotTrigger;
}
// 工作时段(spec: "current time is within configured work hours")
let now_time = now.time();
let start = parse_hhmm(&settings.reminders.work_start);
let end = parse_hhmm(&settings.reminders.work_end);
if !within_work_hours(now_time, start, end) {
return StrongDecision::NotTrigger;
}
// 未暂停(spec: "user is not in a paused state")
if let Some(until) = state.pause_until {
if now < until { return StrongDecision::NotTrigger; }
}
// 距上次间隔(spec: "time since last completed reminder ≥ interval")
let interval_secs = (settings.reminders.interval_minutes as i64) * 60;
let effective_last = state.last_strong_at.unwrap_or(now);
let elapsed = (now - effective_last).num_seconds();
if elapsed < interval_secs {
return StrongDecision::NotTrigger;
}
// 跨下班前 20 分钟静默(spec: "Cross-period reminders")
let end_secs = (end.hour() as i64) * 3600 + (end.minute() as i64) * 60;
let now_secs = (now_time.hour() as i64) * 3600 + (now_time.minute() as i64) * 60;
let mute_sound = (end_secs - now_secs).abs() <= 20 * 60;
StrongDecision::Trigger { mute_sound }
}
5 个分支判断、每条都对应 spec 一句话、命名都对得上 spec 词(pause_until、last_strong_at、mute_sound)。这就是 spec 能让 AI 不发散的物证。
10.3 ui/spec.md → tauri.conf.json 的 4 窗口
specs/ui/spec.md 规定了 5 个 UI:
| UI | 实现 |
|---|---|
| 主界面 | src/App.tsx + tauri.conf.json::main(480×360) |
| 强提醒弹窗 | src/reminder/ReminderPopup.tsx + tauri.conf.json::reminder(320×200) |
| 软提示 | src/softprompt/SoftPrompt.tsx + tauri.conf.json::softprompt(320×200) |
| 设置窗口 | src/settings/SettingsWindow.tsx + tauri.conf.json::settings(800×600) |
| 托盘图标 | src-tauri/src/tray.rs |
tauri.conf.json:25-65 的弹窗配置逐字对齐 design.md 第七节弹窗规范:
json
{
"label": "reminder", "width": 320, "height": 200,
"transparent": true, "alwaysOnTop": true,
"skipTaskbar": true, "focus": false, "decorations": false
}
transparent + alwaysOnTop + skipTaskbar + focus: false 这四个属性是弹窗必须的。少一个,要么挡焦点,要么在任务栏抢位置。
十一、技术架构:specs 推导出 4 窗口 + tokio + mpsc
11.1 架构设计的真实推导
我一开始不会问「tokio 多任务 mpsc 事件总线」。我连 tokio 是什么都不知道。
但写完 specs/spec.md、列完 tasks.md T00 到 T22 后,架构自己浮现了:
- T06 屏幕统计要 30s 轮询 → 需要 async 任务
- T07 提醒调度要订阅 T06 事件 → 需要 mpsc channel
- T09/T10/T11 多窗口要同步状态 → 需要共享 Arc
- T08 托盘菜单要触发「暂停」→ 需要 SchedulerControl 通道
- T13 开机自启 → 已有
tauri-plugin-autostart
落到 src-tauri/src/lib.rs:102-156:
rust
.setup(move |app| {
install_tray(app.handle())?;
let db: tauri::State<'_, Arc<DbState>> = app.state();
// 三条独立 mpsc 通道(specs 推导)
let (stats_tx, stats_rx) = tokio::sync::mpsc::channel(32);
let (reminder_tx, reminder_rx) = tokio::sync::mpsc::channel(32);
let (control_tx, control_rx) = tokio::sync::mpsc::channel(8);
let reminder_state = Arc::new(tokio::sync::Mutex::new(
reminders::ReminderState::default()));
app.manage(Arc::clone(&reminder_state));
// 1) T06 屏幕统计
tauri::async_runtime::spawn(statistics::StatisticsLoop::new(
Arc::clone(&db), stats_tx).run());
// 2) T07 提醒调度器
tauri::async_runtime::spawn(reminders::ReminderScheduler::new(
Arc::clone(&db), settings_arc, stats_rx, reminder_tx, control_rx)
.run(Arc::clone(&reminder_state)));
// 3) ReminderCommand → 前端事件总线
tauri::async_runtime::spawn(async move {
while let Some(cmd) = reminder_rx.recv().await {
windows::translate_command_to_frontend(&app_handle, cmd).await;
}
});
app.manage(SchedulerControlHandle(control_tx));
Ok(())
})
这是「从 spec 推导架构」的真实路径。不是先画架构图再写代码,是先写 spec,架构自然浮现。
11.2 调度器主循环
来源:reminders.rs:663-690
rust
loop {
tokio::select! {
maybe_ev = self.stats_rx.recv() => {
// T06 心跳 / 锁屏事件
}
maybe_ctrl = self.control_rx.recv() => {
// 前端 skip / complete / 测试按钮
}
_ = ticker.tick() => {
// 每 5s 节拍:跑 should_trigger_* 纯函数
}
}
}
判定逻辑全是 pure 函数,单测覆盖率 100%。异步外壳只负责 IO 与状态变更。这个分离是 vibe coding 时让 AI 不发散的关键约束。AI 写 pure 函数比写异步状态机靠谱十倍。
十二、技术栈选型:specs 推导出每一条依赖
我没有一开始就知道要用这些依赖。是写 specs/tasks 时一个一个推导出来的。
12.1 后端依赖推导链
| task 暴露的需求 | 选型 | 推到依赖的理由 |
|---|---|---|
| T05 要调 Win32 API | windows = "0.58" |
Tauri 不内置,要 windows crate |
| T06 要 30s 轮询 | tokio = "1" |
Tauri 2 默认 runtime 就是 tokio |
| T04 要本地 DB | rusqlite = "0.32", features=["bundled"] |
bundled 避免用户装 VC++ Redist |
| T03 要 JSON 设置 | serde + serde_json + dirs |
dirs 拿 %APPDATA% |
| T13 要开机自启 | tauri-plugin-autostart = "2" |
Mac 用 LaunchAgent / Win 写注册表 |
| T08 要系统托盘 | tauri = { features = ["tray-icon", "image-png"] } |
Tauri 2 内置 tray |
| T06 要时间戳 | chrono = "0.4" |
db.rs 用 YYYY-MM-DD 作 daily_stats 主键,需 chrono 做本地时间格式化 |
| T09 主界面「打开 release 页」 | tauri-plugin-opener = "2" |
Tauri 2 官方 plugin,调系统默认浏览器 |
12.2 前端依赖推导链
| task 暴露的需求 | 选型 |
|---|---|
| T09/T10/T11 多窗口 UI | react@19 + react-dom@19 |
| 状态要在多窗口同步 | zustand@4(轻量、无 boilerplate) |
| T11 设置页要滑块 / 复选 / 单选 | @radix-ui/{slider,checkbox,radio-group} |
| T09 主界面要图标 | lucide-react@0.469 |
| design.md 的色号要变 className | tailwindcss@3 |
| 构建速度 | vite@7 |
| 类型安全 | typescript@5.8 |
12.3 为什么是 Tauri:5 个推导条件
我不是看了「Tauri vs Electron」对比表就拍板。是这么推导的:
- Impact 段写「运行时占用 < 20MB」→ Electron 单进程 150MB+ 直接出局
- Impact 段写「目标用户是干眼患者,常驻后台」→ 启动越快越好,Tauri 70ms(README 实测)vs Electron 1-2s(行业经验值)
- task T02 要 NSIS 打包 → Flutter Desktop 没有原生 NSIS,排除
- task T05 要 Win32 API → 排除跨平台优先的方案,选 Tauri(Rust + 系统 API 友好)
- 我自己是 Rust 入门者 → Wails(Go)+ Flutter Desktop(Dart)= 新语言 → 选 Tauri
Tauri 是被这 5 个 specs 推导出来的唯一选项。
十三、踩坑复盘:每个 bug 都回头补 spec
| # | Bug | 我原来没想清楚的 | OpenSpec 怎么帮我补 |
|---|---|---|---|
| T26 | active 期间被重发,两个弹窗倒计时互相覆盖 | 「active 期间还要不要再触发」没想 | spec 加 Scenario「active 时不重发」(reminders.rs:329 / 1077) |
| T29 | 锁屏检测永远 true | 「Tauri 进程在 system session」没意识到 | 用「3 信号代理」过渡:前台窗口消失 + idle > 5s;OpenInputDesktop 真锁屏 API 推迟到 v0.2 |
| T36+ | 装上立刻弹眼药水 / 热敷 / 强提醒 | 「首次启动 UX」没想 | reminders.rs 多处 last=None → far past 逻辑,强制等满 interval |
| NSIS | 安装包启动后图标丢失 | 「图标变体」没列 | tauri.conf.json 的 icon 数组补齐 5 个变体(32×32 / 128×128 / @2x / ico / png) |
| windows crate | Windows API 跨平台编译报错 | 「仅 Windows 编译」必须约束 | Cargo.toml 用 [target.'cfg(windows)'.dependencies] 隔离 |
踩坑一定要回头补 spec。T29 我记得最清楚。凌晨 2 点我让 AI 重写锁屏检测,它给的第一版还是用 OpenInputDesktop 句柄对比。我提醒了一句「Tauri 进程可能在 system session」,它立刻改成「3 信号组合」,一跑就过了。那个瞬间我意识到:spec 的边界真的能被 AI 严格遵守,前提是 spec 写得明确。
十四、技术评析:OpenSpec vs 其他「帮我想清楚需求」的方案
14.1 OpenSpec vs PRD vs User Story vs 原型设计
下表是我个人用过这 4 种工作流后的主观印象,不是行业基准。OpenSpec 这列基于沐目仓库真实结构;其余 3 列只是行业印象的简化,读者可以用自己的经验对线。
| 维度 | OpenSpec | PRD | User Story | Figma |
|---|---|---|---|---|
| 写规格 | WHEN/THEN | 散文 | As a / I want / So that | 不写规格(主写视觉) |
| 视觉规范 | design.md | 单独文档 | 单独文档 | 视觉原型 |
| 实施任务 | tasks.md | 不管实施 | Sprint 拆分 | 不管实施 |
| AI 友好 | 最高 | 低 | 中 | 低 |
| 个人开发者友好 | 最高 | 低 | 中 | 中 |
| 团队协作 | 中 | 高 | 高 | 高 |
| 沉淀 | archive/ | Wiki 散落 | Jira / Linear | Figma 文件 |
14.2 OpenSpec 最适合的场景
- 个人开发者 + AI 协作(vibe coding 时代的最优解)
- 产品边界模糊,需要想清楚才动手
- 小工具 / 垂直工具 / 自用工具
不适合:
- 大型企业产品(PRD 更适合)
- 探索型项目(不需要规格也能跑)
14.3 如果你也是「脑子里空白」的状态
按这个顺序来:
markdown
1. 打开 opsx:explore,问 AI「我想做 X」,它反问你 8-10 个问题
2. 回答完后,AI 给你一份「思考纪要」
3. 用思考纪要写 proposal(Why / What Changes / Impact / Capabilities)
4. 用 proposal 写 design.md(视觉 / 交互 / 文案)
5. 用 proposal 写 specs/*.md(每个能力的 WHEN/THEN)
6. 用 specs 写 tasks.md(实施步骤 + 故意没做 + 验收)
7. opsx:apply,开始写代码
写完 design.md,你已经知道 UI 长啥样。写完 specs,你已经知道行为边界。写完 tasks,你已经知道架构与选型。代码反而是最简单的部分。
十五、全文总结
- 「想做个产品」的鸿沟,技术从来不是问题,思考才是。你不知道要问自己什么问题,所以永远得不到答案。
- OpenSpec 的价值在于帮我和自己聊清楚。proposal 答 Why,design 答 Look,specs 答 What,tasks 答 How,每一类产物对应一类我需要回答的问题。
- 技术栈选型是被 specs/tasks 一个一个推导出来的。5 个推导条件推出 Tauri,是这套方法论的副产品。
- 架构是 specs 写完自然浮现的。4 窗口 + tokio 多任务 + mpsc 通道,是 specs 列完后唯一合理的方案。
- 踩坑一定要回头补 spec。T26/T29/T36 三个 bug 全部沉淀为新的 Scenario,否则下个项目继续踩。
十六、开源项目,欢迎试用与参与
沐目已发布 v0.1.0,MIT 协议开源。
16.1 仓库地址
- GitHub:studyllm/mumu
- 发布页:Releases
- 安装包:
沐目_0.1.0_x64-setup.exe(2.85 MB,Windows 10/11)
16.2 我开源它的两个理由
我是干眼患者,知道这种工具对小众人群才有用。国内有大量长期面对屏幕的人存在不同程度干眼症状,这款工具如果只服务我一个人,价值有限。如果有 100 个人用了,哪怕只有 10 个人觉得「眼睛没那么干了」,这件事就值得做。
它是一份完整的方法论样本 。仓库 openspec/changes/archive/2026-07-20-add-mumu-eye-care/ 下放着一套从 0 到 v0.1.0 的完整记录:1 份 proposal / 1 份 design.md / 4 份 spec / 24 个 tasks。如果你想试 vibe coding 但不知道项目怎么起步,fork 这个仓库换一个 idea 走一遍 OpenSpec 全流程,比看 10 篇教程更快。
16.3 v0.1.0 已经能做什么
- 强提醒弹窗(20-20-20 法则)
- 弱提示(眼药水 / 热敷)
- 每日屏幕使用统计
- 托盘菜单(暂停 30 分钟 / 1 小时 / 到明早 9 点)
- 开机自启
- 性能达标:内存 33MB / CPU 0.26% / 启动 70ms
16.4 v0.2 已经在规划中
- 色温调节(跟随时间变化)
- 历史数据趋势 + 周报
- macOS 移植(Tauri 已支持,等人接)
16.5 我希望谁来参与
不需要你是 Rust 大佬。下面这几类人都欢迎:
| 你是什么人 | 怎么帮 |
|---|---|
| 干眼患者,自己用得着 | 提 issue 反馈 bug 和体验问题,比 PR 更有价值 |
| 前端 / React 同学 | 改 UI 组件、调动画、做暗色模式 |
| Rust 入门者 | 接 v0.2 的 macOS 移植,Tauri 跨平台文档够用 |
| AI / Prompt 工程师 | 帮 OpenSpec 优化它的探索 prompt |
16.6 如果你也在做「从一句话 idea 到 v0.1」的项目
不管你用 OpenSpec 还是别的什么工作流,我想说一件事:3 周做不完的项目不要硬做。
沐目做出来只有一个目的:让我自己能用上。色温调节、跨平台、深色主题......这些都属于「我现在不需要」的功能,我都没做。
做出来能跑、哪怕丑陋、能发出去让别人用------这比完美的架构图重要 100 倍。
沐目的下一版改进,从你试用开始。
附录 A:沐目 OpenSpec 关键路径速查
| 想看什么 | 路径 |
|---|---|
| 全局配置 | mumu/openspec/config.yaml |
| 完整变更(archive) | mumu/openspec/changes/archive/2026-07-20-add-mumu-eye-care/ |
| 提案 | mumu/openspec/changes/archive/.../proposal.md |
| 视觉规范 | mumu/openspec/changes/archive/.../design.md |
| 实施任务(T00 / T00.5 学习准备 + T01-T22 实施 + T10.5 弱提示) | mumu/openspec/changes/archive/.../tasks.md |
| 行为规格 1(reminders) | mumu/openspec/changes/archive/.../specs/reminders/spec.md |
| 行为规格 2(statistics) | mumu/openspec/changes/archive/.../specs/statistics/spec.md |
| 行为规格 3(ui) | mumu/openspec/changes/archive/.../specs/ui/spec.md |
| 行为规格 4(settings) | mumu/openspec/changes/archive/.../specs/settings/spec.md |
| 调度器核心实现 | mumu/src-tauri/src/reminders.rs (1558 行) |
| 锁屏 / 息屏检测 | mumu/src-tauri/src/screen_state.rs |
| tokio 任务装配 | mumu/src-tauri/src/lib.rs:102-156 |
| Tauri 命令桥 | mumu/src-tauri/src/commands.rs |
| 4 窗口配置 | mumu/src-tauri/tauri.conf.json:13-66 |
| Zustand 状态(设置) | mumu/src/stores/settings.ts |
| Zustand 状态(屏幕使用统计) | mumu/src/stores/stats.ts |
| Tailwind 色号(来自 design.md) | mumu/tailwind.config.js |
附录 B:OpenSpec 工作流命令速查
bash
## 附录 B:OpenSpec 工作流命令速查
```bash
# 探索:AI 反问你 8-10 个问题,帮你梳理需求
opsx:explore "我想做一款护眼工具"
# 提议:一条命令同时生成 proposal + design + specs + tasks 4 个文件
# 参数是 change ID(kebab-case + 动词开头,在 openspec/changes/ 下唯一,如 fix-t29-lock-detection)
opsx:propose fix-t29-lock-detection
# 实施:边做边勾选 task
opsx:apply
# 归档:实施完成后归档,保留审计轨迹(生成 openspec/changes/archive/<change-name>/)
opsx:archive
如果你脑子里有个 idea 但完全不知道怎么动手,别打开 IDE,也别打开 Cursor。先在终端跑 opsx:explore "我想做 X",让 AI 反问你 8 个问题。然后你回答。然后你跑 opsx:propose,AI 把 4 类文档一次生成。
审完 4 类文档后,代码反而是最简单的部分。3 周后回看,你会感谢那个一开始脑子里空白、但愿意坐下来写规格的自己。