DESIGN.md · diff

git:20260921.b064504 to git:20261017.565e2ce

208 added, 13 removed. Audit A to A.

# Tadado 详细设计说明 —— v1.x(Tadado2 · Tauri 版)
> 本文档按功能模块记录需求、实现方案和界面布局标注,用于快速项目重建和迭代参考。
> 「怎么跑 / 目录里有什么」看 [`desktop/README.md`](desktop/README.md),
> 运行时 AI 指令参见 [CLAUDE.md](CLAUDE.md),更新日志参见 [CHANGELOG.md](CHANGELOG.md)。
>
> **两条版本线各自维护自己的文档**:
>
> | 版本线 | 分支 | 形态 | 设计文档 |
> |:--:|---|---|---|
> | **v1.x(当前)** | `main` | Tadado2 · Tauri 2 | **本文件** |
> | v0.x | `archive/pyversion` | PySide6 | 那条分支上的 `DESIGN.md` |
>
> 两版**零数据共享**,但共享同一份**设计语言**(语义色、几何、暖灰双主题)与同一份**领域概念**
> (分区、优先级 P0–P3、状态、活动时间线)。v1.x 在几个地方**有意与 v0.x 分叉**,
> 分叉点在本文件里逐条写明。
>
> **路径约定**:本文件提到的源码路径**均相对 `desktop/`** —— 写 `src/data/db.ts` 时,
> 实际位置是 `desktop/src/data/db.ts`。
>
> 取归档分支上的东西:
>
> ```bash
> git show archive/pyversion:DESIGN.md # Py 版设计文档
> git show archive/pyversion:src/utils/design_tokens.py # 语义色权威源
> git checkout archive/pyversion -- <路径> # 取单个文件或整目录
> ```
---
## 1. 总览
### 1.1 技术栈
| 层面 | 技术 |
|------|------|
| 外壳 | Tauri 2(Rust) |
| 渲染 | 系统 WebView2(Windows)+ TypeScript,无框架 |
| 构建 | Vite 8 · TypeScript 6(`strict`) |
| 存储 | SQLite(`tauri-plugin-sql`,feature `sqlite`) |
| 测试 | Playwright 冒烟(真 Chromium 点一遍)+ `tsc --noEmit` |
| 打包 | Tauri bundler(NSIS / MSI)+ 便携 zip |
**无框架是刻意的**:界面是「外壳 + 五个页面 + 几个浮层」,没有路由表要维护、没有虚拟 DOM 要调和。
`el()` 一个 DOM 助手(`shell/dom.ts`)就够把结构写清楚;换框架带来的收益(组件复用、响应式)在这里
用不上,代价(构建链、抽象层、首帧)却是实打实的。
**无运行时依赖**:产物是单文件 exe,用户机器上不需要 Node、Python 或任何运行库 —— 这是相对 Py 版
(PyInstaller 要打包整个 Python 运行时)最直接的收益。
### 1.2 分层
```
desktop/
index.html 应用骨架:标题栏 + rail + 主区 + 设置抽屉(静态壳)
src/main.ts 入口:只按顺序调用各模块的 mount,不放业务逻辑
src/styles.css 样式入口:按依赖顺序 @import 五层 css
src/data/ 数据层(不认识 DOM)
types.ts 领域类型:TaskStatus / Urgency / MonthDay / At / Activity / Task
time.ts 时间基准 TODAY、dayNumber、时刻与文案换算
db.ts 持久化:SQLite / localStorage 双后端、读写、backend 状态机
schema.ts 存储版本 + 迁移链 + 读入口归位
store.ts 变更广播 dataChanged / onDataChange、逾期重算、完成后归档、完成日推算
mock.ts 种子数据:演示空间 100 条
partitions.ts 分区列表 / 当前分区 / 默认分区
tags.ts 标签规则(上限 3、未分类)
markdown.ts 任务行写法:parseTasks(入)/ taskToMarkdown(出)
export.ts / xlsx.ts 导出:三种格式的同一份数据;xlsx 自写 zip
timeline.ts 时间轴档位与窗口算数
src/shell/ 外壳能力(跨页面、不认识业务)
dom.ts / toast.ts 极简 DOM 助手、提示条
theme.ts / scheme.ts 主题(light/dark/sys)、热力图配色
titlebar.ts / window.ts 标题栏装配、窗口能力(Tauri 环境降级)
nav.ts / router.ts rail 渲染与页面挂载、页面切换状态
partition.ts 分区切换器
settings.ts 设置抽屉
+ update.ts / links.ts 检查更新(Rust 命令的门面)、外链与复制(下载渠道常量都在这儿)
lock.ts 分区口令与空闲锁定
hotkey.ts / trayBridge.ts / autostart.ts 全局热键、托盘事件、开机自启
rollover.ts 跨日续跑:托盘常驻过了午夜,让「今天」重新算一遍(重载)
confirm.ts / prompt.ts 二次确认、单行输入浮层
- panels.ts 右侧抽屉互斥
+ panels.ts 右侧抽屉互斥与「点外部收起」
menu.ts / seg.ts / dateTime.ts 下拉、分段、日期时间输入
download.ts / exportMenu.ts 落文件、「导出 ▾」按钮
src/pages/ 页面层
registry.ts PageId / PageSpec / PAGES —— 导航与快捷键的唯一来源
index.ts PAGE_VIEWS:PageId → 实现(与 registry 分开,避免循环依赖)
shared.ts 跨页共用的常量与展示换算
overview.ts / tasks.ts / graph.ts / activity.ts / manage.ts
taskDrawer.ts / taskForm.ts 维护抽屉、定义表单
focus.ts 跨页「定位到任务」的单向请求
pager.ts 分页器(三张表共用)
src-tauri/
src/lib.rs 插件注册、托盘、app_exit 命令、单实例
src/main.rs 只调 desktop_lib::run()
tauri.conf.json 窗口与打包配置
capabilities/default.json 权限清单(白名单,逐项授予)
```
**依赖方向是单向的**:`data ← shell ← pages`。`data/` 不许碰 DOM,`shell/` 不认识业务对象,
`pages/` 只经由 `shell/` 提供的能力动窗口与浮层。这条线撑住了「页面可以整体替换」这件事 ——
本文档后面每一节的实现方案都依赖它。
### 1.3 启动装配顺序(`main.ts`)
`boot()` 的调用顺序**不是随手排的**,每一处都有理由:
| 序 | 调用 | 为什么是这个位置 |
|:--:|------|------------------|
| 1 | `initTheme()` | 主题先落 `data-theme`,否则首帧闪白 |
| 2 | `initScheme()` | 热力图色阶同理,要在画格子之前定好 |
| 3 | `await bootStore()` | **数据先装好再画页面**:反过来的话第一帧画的是种子数据,存档一到位整屏跳一次 |
| 4 | `await bootPartitions()` | 先读存档再用 —— 用户自己分过的区不能被内置那四个盖回去 |
| 5 | `await bootLock()` | 排在画页面之前:要先决定这一屏要不要挡一层锁屏 |
| 6–9 | `mountTitlebar()` `mountNav()` `mountPartition()` `mountSettings()` | 骨架装配 |
| 10 | `storageIssue()` → `toast` | 存储有问题在这里**说出来**,不静默 |
| 11 | `mountTrayBridge()` | 托盘事件的前端一半 |
| 12 | `await initWindowState()` | 对齐窗口真实状态(置顶 / 最大化),避免按钮显示与实际不符 |
| 13 | `if (!isTauri()) return` | 浏览器预览到此为止,下面都是宿主能力 |
| 14 | `await setupHotkey()` | 失败要**告警**:热键挂了、窗口收起后唤不回,用户会以为应用死了 |
| 15 | `watchDayRollover()` | 托盘常驻会**开着过夜**:过了午夜得把「今天」重新算一遍(见 §5.2) |
### 1.4 与 Py 版(v0.x)的关系
- **2026-09-15 分家**:PySide6 版连同 `src/` `tests/` `scripts/` `resources/themes/` `pyproject.toml`
全部归档到 **`archive/pyversion`** 分支;`main` 只推桌面端。两条线**各自维护自己的代码与文档**。
- **零数据共享**:两版数据目录不同(v1.x 在 `%APPDATA%\com.tadado.app\`,v0.x 是 `resources/tadado.data`),
装 v1.x 不会动旧版。旧数据走**导出 → 转换 → 批量新建**(见 §7.1)。
- **`identifier` 不能改**:`com.tadado.app` 决定数据目录。改它等于让用户现有的库、窗口位置、设置
全部「消失」。同理 `tadado.data` 这个库名、localStorage 的键、Cargo 的 package name(开机自启的
注册项按它走)都保持原样。
---
## 2. 视觉与样式
### 2.1 两个权威源
颜色和几何**不许在这里自由发挥**:
| 内容 | 权威源 |
|------|--------|
| 语义色、字体栈 | `archive/pyversion:src/utils/design_tokens.py`(随 Py 版归档,见文件头的取法) |
| 圆角、控件尺寸、阴影、动效曲线 | [`resources/ui-mockup/tadado-2.0.html`](resources/ui-mockup/tadado-2.0.html) |
| 窗口形态、导航骨架 | `archive/pyversion:DESIGN.md` 的 2.12 / 1.3.1 两节 |
`src/styles/tokens.css` 只做一件事:把上面两者翻译成 CSS 变量。**不要新增颜色** ——
一旦出现字面量色值,主题(亮/暗)就会在某处漏掉一块。
### 2.2 五层样式与依赖方向
`src/styles.css` 按顺序 `@import`:
```
tokens → CSS 变量(语义色 + 几何 + 字体 + 热力图色阶)
base → reset / 排版 / 滚动条 / 焦点环 / 提示条
shell → 应用骨架:标题栏 + rail + 主区 + 抽屉
controls → 通用控件:按钮 / 胶囊 / 分段 / 输入 / 下拉 / 卡片 / 开关
pages → 五个视图各自的部件 + 维护抽屉内容
```
**方向只能是 `tokens → base → shell → controls → pages`**。改样式前先确认改的是哪一层:
变量不许塞进控件层,控件的几何不许在页面层重写。
`pages.css` 有两处**刻意改名**,别照着原型搜索:原型的 `.tile.tl` → `.tile.clickable`(原名在 CSS 里
是没有意义的缩写),原型的全局 `table` → `.tabwrap`(全局选择器会波及抽屉里的表格)。
### 2.3 一屏到底(布局的总原则)
**主区不滚**。五个页面各自撑满窗口:固定的块(指标卡、热力图、工具行)留在原地,**会长的那一块**
(表格 / 列表)在卡片内部吃掉剩余高度并在那里滚。这样翻页与筛选时,页头、卡片头、分页器不会被滚走。
高度是这样传下去的:
```
.main{overflow:hidden} 主区不滚
└ .page{height:100%; display:flex; flex-direction:column}
└ .inner{flex:1; min-height:0}
├ .ph{flex:none} 页头留在原地
└ .page-body{flex:1} 固定块 flex:none,会长的那块 flex:1
```
**四个坑**(都是实测踩出来的,改布局前先读):
1. **grid 的行高默认由内容决定**。列表装了 20 行就把行高顶到 700px,容器被压扁后行高照旧 ——
多出来的部分顶着整页滚(总览实测溢出 186px)。要 `grid-template-rows: minmax(0,1fr)`
把行高钉在容器高度上,卡片再靠内部的 flex 链把列表压进剩余空间。
2. **无名 div 会断链**。块级容器不做 flex 分配,链断在那里 `flex:1` 全落空。总览那几块外面
原来包着一层没有样式的 div,因此给它一个 `.ov-page` 才把链接上。
3. **卡片等高之后,绝对定位的元素会盖住下一张卡**。时间轴上的气泡多出几像素就越界,不只是难看 ——
**被盖住的按钮点不到**(e2e 就卡在那一步重试到超时)。撑满场景的 `.card-b` 要 `overflow:hidden`。
4. **每一层都要 `min-height:0`**。flex 子项默认不会被压矮,漏一层这条链就断在那一层。
**不要用 `calc(100vh - Npx)` 估可视高度**:那是一串写死的层高,多一层少一层就估错,估错一次
就有一截内容被切掉。任务页原来就是这么干的(`calc(100vh - 268px)`),现在换成了同一条 flex 链。
**分页与屏内滚并存**:每页条数仍是分页器上的档位(`PAGE_SIZES = [20, 30, 50, 100]`),
屏内滚只是兜底 —— 一屏装 13 行时看第 14 行要滚,但**不必滚整页**。
### 2.4 窗口形态
| 参数 | 值 | 理由 |
|------|-----|------|
| 尺寸 | 1180 × 760 | 一屏装得下五个页面的主内容 |
| 最小尺寸 | 1050 × 680 | 低于这个尺寸,任务页的列吸附会开始互相压 |
| `decorations` | `false` | 自绘标题栏(样式与原型对齐) |
| `resizable` | `true` | **保留系统缩放热区与 Aero Snap** —— 自绘标题栏不能连边缘拖拽一起丢掉 |
| `transparent` | `false` | 页面有实底,透明窗口没有收益且合成更贵 |
| `alwaysOnTop` | `false` | 启动不置顶,由标题栏的图钉按钮控制 |
| 标题栏拖拽 | `data-tauri-drag-region="deep"` | 让子元素所在区域也能拖 |
| 关闭按钮 | 收起常驻 | 真正退出走托盘菜单的 `app_exit` |
`additionalBrowserArgs` 里关掉了 WebView2 的若干特性(`msWebOOUI` / `msPdfOOUI` /
`msSmartScreenProtection` / `CalculateNativeWinOcclusion`)并禁用了后台节流 ——
关前三个是为了去掉页面里的浏览器痕迹,最后一个是为了让窗口被遮挡时计时器不被降频。
---
## 3. 数据层
### 3.1 存储形态
```sql
CREATE TABLE IF NOT EXISTS tasks (
id TEXT PRIMARY KEY NOT NULL,
partition TEXT NOT NULL,
archived INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL,
start TEXT NOT NULL, -- MM-DD
end TEXT NOT NULL, -- MM-DD
data TEXT NOT NULL -- 其余业务字段整条 JSON
);
CREATE TABLE IF NOT EXISTS kv (
key TEXT PRIMARY KEY NOT NULL,
value TEXT NOT NULL
);
```
**只有六个字段立在列上**(够用来筛选、排序、分组),其余业务字段整条塞进 `tasks.data`。
这么分的理由:列一旦立起来就要跟着迁移走,而业务字段还在长(加一个字段就得写一次迁移,
不值当)。代价是「列里的结构」没有版本号管 —— 由 §3.3 的读入口负责。
写策略是**全量覆盖**(先 `DELETE FROM tasks` 再逐条 `INSERT`,防抖 400ms)。
### 3.2 版本与迁移
- 版本号走 `PRAGMA user_version`,**跟数据库文件走**(和 Py 版同一套来源)。没有版本号就等于
每次启动都可能对着一个未知结构跑 —— 「现在能用」不等于「结构受控」。
- `MIGRATIONS: {from, to, sql?, run?}[]` 是**线性链**,启动时读当前版本顺序跑完写回。
规矩照搬 Py 版 `src/models/migrations.py`:**只能加不能改**(只 `ALTER TABLE ADD COLUMN`,
不删列、不改列类型)。
- 链的第一环 `0 → 1` 就是「确保这套表在」。**老库的 `user_version` 读出来也是 0**,
于是它一启动就被带上版本号、数据一条没动 —— 这是存量库零成本升级的路子。
- `setVersion` 写完 PRAGMA 会**在同一条连接上读回核对**,不一致直接抛。PRAGMA 万一被忽略
是静默的,而机制静默退化(每次启动重跑一遍迁移)要等某天真加了破坏性迁移才会暴露 ——
那时它已经在用户的库上跑过好几遍了。
> **一条教训**(值得单独记):曾经我直接读库文件头判断 `user_version`,读到 0 就断定「PRAGMA 写不生效」,
> 还把版本号搬去了 kv 表。结论是错的 —— 应用当时还开着,最新写入躺在 `-wal` 里没 checkpoint。
> **外部读到的值可能是陈旧视图,别拿它去推翻一个还在运行的进程。**
浏览器预览没有 SQLite,版本号落在 localStorage 的 `tadado.schema.version`,但**迁移链是同一条**,
所以 e2e 能真的把迁移跑一遍。
### 3.3 读入口归位(`normalizeTasks`)
`tasks.data` 里的结构版本号管不到,只能靠读入口:缺字段补默认、类型不对的救回来、
旧格式(活动时刻曾是「昨天 17:20」这类展示串)换成时间戳、认不出的活动类型退成 `log`、
连 `id` 或标题都没有的整条丢掉并计数(**不静默**,控制台有记录)。
原来这里是 `JSON.parse(...) as Task` —— **断言不是校验**:少一个字段就是 `undefined` 一路带到界面
(`tags.join()` 直接抛)。同类处理还有 kv 值的取证:`asStringRecord` / `asNumberRecord` /
`asArchiveRecord` / `asPartitionList`,形状不对就当没设过,而不是读出来直接用。
⚠️ **归档档位为什么单独一份 `asArchiveRecord`**:它这一路有**负数**档位(`-1` = 立即,见 §4.10),
而 `asNumberRecord` 是按「分钟数」写的、把负数当脏数据丢掉。两者共用过一阵,结果是「立即」写下去、
读回来变成 `undefined` —— 启动时被当成「这个分区从没设过」,于是被 §4.10 那条老库冻结写成「不归档」。
**「什么值算合法」这个知识属于消费它的那条路**,不是一份通用的「是个数字就行」。
### 3.4 故障不降级
`db.ts` 的 `backend` 有四个取值,其中一个是**终态**:
| 值 | 含义 | 行为 |
|----|------|------|
| `sqlite` | 宿主在、库打开成功 | 正常读写 |
| `local` | 不在 Tauri 里(浏览器预览) | 降级 localStorage |
| `error` | **宿主在、库却打不开** | **终态**:不降级、不重试、不写入 |
| `unknown` | 初始值 | —— |
判断顺序是**先看有没有宿主,再看库能不能开**。以前那个 `catch` 把这两种情况当成同一件事,
一律退回 localStorage —— 后果是用户的库静静地不被读取、界面一个字不说、此后所有写入都落到
另一个地方(数据被劈成两半,而表面上一切正常)。
配套两道闸:`readIssue` 非 null 时调用方**不能**当空库处理(`bootStore` 只清内存 + 广播,不播种,
否则「读不出来」会被种子数据**顶替**掉,看起来像正常);读失败后 `writeBlocked` 禁止写入 ——
少了这条,「坏存档原样留着」只是句好话:界面继续跑,用户随手一点就触发 `dataChanged` 全量覆盖,
那份还能人工捞回来的存档当场没了。外壳用 toast 把原因说出来。
### 3.5 kv 的 key 清单
| key | 用途 |
|-----|------|
- | `archive.afterDays` | 各分区的**完成后归档**档位(`{分区id: 天数}`:`0` = 不归档 / `-1` = 立即 / `N` = 完成后 N 天。**负数编码见 §4.10** —— 老数据里 `0` 就是「关」,沿用才不会把老用户的设置读歪) |
+ | `archive.afterDays` | 各分区的**完成后归档**档位(`{分区id: 天数}`:`0` = 不归档 / `-1` = 立即 / `N` = 完成后 N 天。**负数编码见 §4.10** —— 老数据里 `0` 就是「关」,沿用才不会把老用户的设置读歪)。新建的分区由 `store` 订阅分区列表变更时补成「立即」并**落盘**(见 §4.10) |
| `partitions.list` / `partitions.default` | 分区列表、启动进哪个分区 |
| `lock.passwords` / `lock.idleMinutes` | 各分区口令、空闲锁定分钟数 |
| `tadado.tasks.v1` / `tadado.kv.` / `tadado.schema.version` | **仅浏览器后端**:任务存档 / 设置 / 版本号 |
### 3.6 演示数据与分区
- 样例数据**只有一份,全部落在演示空间**:100 条 = 28 条原型手写(与 `resources/ui-mockup/tadado-2.0.html`
逐字一致,方便并排比对)+ 72 条按序号**确定性**生成(随机会让「刚才那条去哪了」变成没法回答的问题)。
生成器同时是「一屏画多少条」这类上限的真实输入。
- **默认分区就是演示空间**:否则开机进「工作」是一片空,看起来像数据没加载。
- **其他分区交付时为空**。空分区不是「数据没加载出来」,而是「这里本来就还没东西」。
- 早先种子按标签匀在四个区(「每区都得有东西可看」),结果是每区都只有几条、看不出规模、压测也压不出东西。
老库里散出去的种子由 `store.ts` 的 `adoptSeedPartitions` 在启动时搬回来 —— **只动种子 id**,
用户自己建的任务一条都不碰。光改种子只对全新安装成立,归位才让这个口径在任何一台机器上都成立。
- `TODAY` 读**真实时钟**;`DEMO_TODAY = [9, 12]` 只是**样品本身的排布锚点**(活动里的相对日期都相对它写)。
两者混用会导致「今天」出现两种口径。
---
## 4. 功能模块
### 4.1 总览
**需求**:一眼看出「今天要做什么」与「最近发生了什么」。
**布局**:问候条 → 五张指标卡 → 下半页**两列**。
- 右列整条归**近期活动**(分页):它就是这页最需要空间的那张表。
- 左列是「今天」:**焦点时间轴** + **优先级分布**。
**近期活动按任务聚合**(2026-09-20):**一个任务一行**,行内给的是它**最新**的一条活动,
后面标出「共 N 条」。以前是一条活动一行 —— 同一个任务连着记三条进展就占三行、标题重复
三遍,扫一眼看见的其实只是「有个任务动了很多次」,而它明明一行就说得清。
**折叠而不报数同样不行**:「动过一次」和「动过八次、只看见最后一条」在这一行上会长得
一模一样,那折叠就成了丢信息。排序按**最新一次活动**倒序(聚成任务之后,「每条活动的
时间」这个排序键就没有了);完整过程在抽屉的时间线里,点这一行就到那儿。
(焦点时间轴随后也改成了同样的按任务聚合,见下。)
**这张卡有上限:只摆最近 50 个任务**(`FEED_LIMIT`,2026-09-20 用户定的 —— 「只想显示
近期的 50 条,不想显示太多」)。上限是这张卡的**定位**:一眼看最近发生了什么;想翻遍历史
(按标签、按时间范围、带分页)去活动分析页。截断发生在聚合排序**之后**,所以「最近的 50」
就是真的最近 50 个动过的任务;分页器只在**这 50 条以内**翻。
聚合之后这张卡会出现两个不同的单位,**数字要少报、单位要写清**(2026-09-20 用户报的):
卡片头的后缀给的是**窗口**而不是总数 —— `按最近活动倒序 · 前 50 个任务`。原来那里写
「共 88 个任务」,88 是聚合后的任务总数,会被读成「这一屏显示了 88 条」,跟每屏行数
(分页器档位 20/30/50,默认 **50** = 上限,见 §4.7)不是一回事;空表时不带这句。
分页器同样**只报范围**(「第 1–50 个任务」),不带「共 N」。每行右端那个「共 N 条」是
**另一个单位**(这一个任务的活动条数),两者不要并排比对。
**为什么分两列**:原来四块纵向堆叠,常见窗口高度下装不下 —— 近期活动只剩三行(实测列表区 100px),
要翻页得先滚。分列之后左列两块正好填满,近期活动列表从 100px 涨到 404px。
**焦点时间轴**是这页最花心思的一块,几条判据都是踩出来的:
- **只列这一天的活动记录**(含「新建任务」那一条),没动静的任务不占位置。它回答的是
「**发生了什么**」,不是「该做什么」——后者归任务页与那排指标卡。
- **气泡也是按任务聚合**(2026-09-20):一个任务占一个位置,写作「任务名 `+N`」(N = 它
这一天更新了几条,绿字 `.tdt-cn`)。最初写的是活动原文,后来改成「任务名 · 更新了 N 条
记录」,还是太长 —— 气泡是轴上的**标记**,一句完整的话会把整条轴挤满,数字才是重点。
明细仍在抽屉的时间线里,点气泡就到。
- **「已过期未完成」与「逾期」是同一个口径**(2026-09-20 收紧):这一栏的判据就是
`status === "overdue"`(`store.refreshOverdue` 的产物)。以前写「未完成 && end < 今天」,
把「进行中但已过期」也算进去(系统**故意不动** doing,见 store),于是栏上报 47、点
「等 N 个」过去逾期清单只有 40 —— 两个数各自都没算错,摆在一起就像数据坏了。收进同一
个谓词后:这一栏 = 「逾期」指标卡 = 任务页的逾期筛选,三处同一个数(与 `isDueToday`、
`isOngoing` 同一条「一个谓词多个消费方」的规矩)。
- **逾期一多,这一栏只摆前 3 条,其余折成「等 N 个」**(2026-09-20):它是**提示**不是
清单,摆全了就把上面的轴挤没了。两个前提:① N 是**总数**(中文「A、B、C 等 N 个」的 N
含前面摆出的,不是减出来的剩余数);② 折掉的部分**有去处** —— 「等 N 个」可点,落到
任务页的逾期筛选,且那里的数与栏上的总数相等(e2e 把这个等式量住)。总条数也仍在
卡片头「过期 N 项未清」里 —— 折的是位置,不是数。
- 判据**不能**是「起止日期盖住今天」:那样结束在 9/12 的任务会杵在 9/16 的轴上写着 08:30,
而旁边卡片写着「今日到期 2」—— 两个数各自都没算错,摆一起就是同一个「今天」两种口径。
- 「今日到期」加了 `due !== null` 判据:迁移来的旧数据里有一批**没有截止**的任务,它们的 `end`
只能落今天,只看 `end` 会把「没有这个信息」显示成一个具体的日子。
- **气泡挤在一起时同侧自动多开一条道**(`spreadPills`)。分道只能在进了文档之后做 —— 气泡的
`left` 是百分比,真实像素要等容器有宽度才量得出来;量不到(页面不可见)就保持原样。
**不采用「重合就切甘特」**:甘特的粒度是**天**、当日轴的粒度是**分钟**,今天的十几条活动切过去
全落在同一格,照样叠着,还顺带把时刻弄丢了。重合是「同一粒度里放不下」,解法只能是加道。
- 轴的基准高度由 CSS 与 JS **共用一个常量**(`AXIS_H`):多开的道是在这个基准上往外加的,
两处对不上就会要么压着气泡、要么白白空一截。
- 轴的量程固定 **06:00–24:00**,「现在」标记读真实时钟。**算出来的百分比必须钳在 0–100**:
凌晨的「现在」是负数(01:00 → -27.8%),不钳就会画到轴的左边之外 —— 压在别的区块上或者
被裁掉,看着像页面坏了。钳到 0 之后它贴在左端,标题里仍写着真实时刻(「现在 01:00」),
信息不丢。对气泡没有影响:它们本来就在 `pct < 1 || pct > 99` 时转到轴下面那条去。
**五张指标卡都是入口**(今日到期 / 逾期 / 进行中 / 已完成 / 归档):卡上的数字就是点开后的
**筛出总数**。`tile()` 里「可点 / 悬停提示 / 点击监听」三件事同源(给了 `onClick` 才都成立),
所以漏传 `onClick` 的后果是**整张卡静默失效**:数字摆在那儿,点上去没反应,页面上也没有任何
地方说得出为什么。「今日到期」就这么漏过一次(2026-09-20 报的),因此有三条规矩:
- 判据抽成 `pages/shared.ts` 的 **`isDueToday(task)`** —— 卡片数它、任务页那条「今日到期」筛选
筛它,**同一个函数**。两处各写一份,就是给「卡片上写 1、点进去 0 条」留门;
- 任务页因此多一枚**不属于状态**的筛选 chip(见 §4.2);
- e2e 的判据改成**遍历界面上所有的 `.tile`**,不再是一张写死的清单:有几张卡就验几张。
写死清单正是它放走这次问题的原因(原来只列了「逾期 / 进行中 / 已完成」三张)。
**⚠️「待办」这一档删掉了**(2026-09-21 用户定的:「默认就是进行中」)。这条翻过一次面,
两段历史都留着:
- 2026-09-20 先把「进行中」做成**合并口径(待办 + 进行中)** —— 理由是「待办只是还没动手,
它同样是手上没做完的活儿」,当时只数 `status === "doing"` 会让今天刚动过的那条不进卡
(卡上写 2、点进去 1);
- **2026-09-21 索性把那一档删了**:既然**统计口径**上早已合一,留着它只剩「多按一次状态按钮」
的成本,而且它是「用户可选状态」里唯一一个**不代表任何实际差别**的值(新建即进行中、
写一条进展也是进行中)。md 写法上 `[ ]` 仍然收(算进行中),见 §4.8。
判据仍抽在 `pages/shared.ts` 的 **`matchesStatus(task, filter)`**:总览那张卡、任务页那排 chip、
管理页的筛选、图谱页的「进行中」过滤 —— **四个消费方共用**(与 `isDueToday` 同一条理由,
各写一份就是给「卡上写 41、点进去 17」留门)。它以前是「一个合并口径 + 三个一对一」的混合体,
**现在是一对一**(那个 `isOngoing` 包装随之删掉)。
三张状态卡仍然把未归档任务**干净切开**:逾期 / 进行中 / 已完成,互不重叠。
**逾期不并进「进行中」**:它有自己的一张卡,两张卡数同一批等于把一件事说两遍,还会把
「最早逾期」这个更急的信息挤掉。
**第五张「已归档」数的是归档的那批**(2026-09-21)。其余四张都只数**未归档**。这张卡的来历是
一个真问题:默认「完成后归档=立即」(§4.10)之下,「完成」与「归档」是**同一瞬间** —— 勾完
「已完成」,它就离开任务页、归档数 +1,而「已完成」那枚数字**先加后减**(同一帧里),看着像
什么也没发生,其实它只是换了个地方。把归档数摆出来,两处数字此消彼长,收走的东西就是
**看得见**的,不是凭空少掉的。
它也是唯一**不落在任务页**的数字卡:已归档的任务在任务页根本不列(归档的意思就是「从「任务」
界面收走」,见 §3.6 的 `activeTasks`),所以点它去**任务管理页的「已归档」**—— 那页是唯一能
看见这批的地方(§4.5)。这是 e2e 那条「遍历所有 `.tile`」的唯一例外,判据因此单独写:卡片数 =
那页筛出的总数,且落点那枚 chip 必须亮着「已归档」。
**归档不改其它卡的口径**:四张卡仍是未归档(`activeTasks`),归档单独一张 —— 这样「已完成掉数」
永远能对上「归档涨数」,不必把归档并进别的卡(并进去的话,那几张卡点开是任务页那批 = 未归档,
立刻变成「卡上 19、点进去 18」)。活动分析的口径本来就含归档(源码里写明「归档只是列表里不再
显示」)。
**已完成 / 归档 / 未归档三者的关系**(2026-09-21 用户问的)。`status`(进行中 / 已完成 /
逾期)与 `archived` 是**两个正交的字段**:前者说「这件事到哪一步了」,后者说「它在不在当前
工作面」。所以**「归档」不是一种状态** —— 取消归档后它的 `status` 一点没变,归档也不改 `status`
(种子里那条样例正是「已完成 + 已归档」:`ref`「旧版重构收尾」)。
- 三张状态卡把**未归档**那批干净切开(逾期 / 进行中 / 已完成,互不重叠);
- 归档卡是它们的**补集**:`未归档 + 归档 = 本分区全部`(`activeTasks()` + `archived` 就是
`partitionTasks()`)。演示数据上算一遍(删掉「待办」、逾期口径放宽成「未完成 + 过期」
之后的实测值):48 + 32 + 0 = 80,加归档 20 = 100;
- **「今日到期」不参与这个等式**:它是**截止日**维度的卡,与状态卡有交集(今天到期的可能是
逾期的,也可能是进行中的);
- 自动归档只收 `status === "done"` 的(判据见 §4.10),所以**自动**进去的一定是已完成;
**手动**归档(管理页那一排按钮)任何状态都能进 —— 归档那批里不一定全是已完成。
+ ⚠️ 判据没变,但**「已完成」的产生路径多了一条**(2026-10-16):进度拖满 100% 会自动置为
+ 已完成、退回 100% 以下会自动退回进行中 —— 进度与状态是**同一个口径的两面**(见 §4.10)。
- 生命周期上的两处相互影响:① `refreshOverdue` **只豁免 `done`**(2026-09-21 改的口径:以前
`doing` 也豁免 —— 那是在「待办」还在时写的,删掉它之后继续豁免就**没有任何任务会逾期**了,
那张卡会永远归零。现在:**逾期 = 未完成 + 过了截止**,「进行中」= 未过期且未完成);
② 手动「取消归档」只在**本次会话**有效(`store` 的 `restored`),
下次启动若自动归档判据仍成立,它会被再收一次 —— 想改成持久的话得把 `restored` 落进 kv。
副标那句「其中 N 个今日更新」也**只数这批**:以前它数的是**全部未归档任务**,于是那个「其中」
是假的 —— 卡上 2 条、副标里那个 1 却可能是某条逾期的,点进去当然找不到它。e2e 用
「**N ≤ 卡上的数字**」把这个不变量钉住(子集不会比全集大)。
(以前还配一条「进行中 chip ≥ 待办 chip」钉那个合并口径 —— 随「待办」一起撤了。)
**优先级分布**:五行 = 四档优先级,每行都是一个入口(点它去任务页按该档筛出)。标签写
**名字 + 编号**(`紧急(P0)`,`shared.ts` 的 `urgencyText()`,2026-09-20 改)—— 只写「紧急」的话
用户得自己记住它对应列表里哪个 `Px`,而列表上那枚徽标偏巧只写 `Px`,两边各说各话。
两处**刻意不带编号**,理由不同:
- `URGENCY_LABEL` 本身不动 —— 它会被拼进**活动记录的文本**(`紧急 → 关注`,见 `taskForm`),
那句话是写进历史的**数据**,读起来该像人话,不该塞进编号;
- 带彩色徽标的地方(编辑面板那排按钮)不用它 —— 徽标本身就是编号,再写一遍就成了
`P0 紧急(P0)`。
规矩一句话:**有徽标 → 徽标 + 名字;没有徽标 → 名字里带编号**。任务页那个筛选下拉也走同一个
函数,于是「点总览某一档 → 任务页顶部下拉」两处文案逐字相同(e2e 直接拿后者去比前者)。
`.ubar .un` 的宽度是**一行的宽度**(56px)而不是两个字的宽度:30px 时括号里的编号会被挤到色条上。
### 4.2 任务(甘特时间轴)
**需求**:看清任务的**时间跨度**,并就地增删改。(**进度不在条上** —— 它在行首那枚进度饼、
悬停小卡与条内那枚白字进度里,理由见下面「条 = 标准甘特」那条。)
- 表格的每一行是一条任务,行内画甘特条:**色条 = 创建日 → 截止日,整块单色 = 状态**
(2026-09-21 起:不再填充进度、也不按档位切段 —— 见「条 = 标准甘特」)。
- ⚠️ **工具行那排档位(全部 / 昨天 / 今天 / 上周 / 本周 / 上月 / 本月)2026-09-21 撤了**
(用户:「我突然感觉这些没有用,如果有反而不容易理解,是不是可以直接删除」)。它 2026-09-20
的用途是「点一下,除了筛出这段时间**动过**的任务,还把**这一段范围内的进度在条上突出来**」
(原话见下面「甘特窗口」那段);而条在那之后就改回了**标准甘特**(整块实色、不画进度、
不按档位切段)—— 这个用途没有承载物了。剩下的那件事(只列这段时间**动过**的任务)读起来
像「看哪一段时间」,实际会让任务成批消失,于是变成「我看到的本周」与「我库里有的」两套口径。
**想看「最近动过的」有更直白的地方**:总览的**近期活动**卡片本来就是按最新活动聚合的
(前 50 个任务,见 §4.1);任务页要收窄范围,状态筛选 / 搜索 / 排序 / 分页就够了。
撤掉之后这一页**没有会自己变的视图状态**:打开就是「这个分区全部未归档任务」。
—— 顺带记一处**文案审计**(2026-09-21):页头那句说明原来写「档位从今天到全年」(关于面板里),
而**从来没有「全年」这一档**,与按钮列对不上;页头说明也写过一串操作手势(「单击选中 ·
双击打开抽屉 · 右键处置 · 批量迁入在任务管理页」)—— 既没说清这一页是什么,还把别页的入口
写进来了。现在页头说明只回答「这里能看到什么」,操作手势交给悬浮提示。
**甘特窗口:固定 32 天 + 拖动平移**(2026-09-20 用户提的大改。原话:「按昨天/今天/上周…呈现的
效果都不好…按创建时间和截止时间创建进度条…点击快捷时间按钮时,除了原有的筛选任务外,重点突出
这个时间范围内的当前任务的进度…32 天显示最美观,其他只需要拖动即可」。⚠️ 其中「点快捷时间按钮
突出这一段范围内的进度」那一半,随那排档位在 2026-09-21 一起撤了):
- **窗口固定 32 天**(`tasks.ts` 的 `GANTT_DAYS`),不按任务跨度撑开。
默认窗口 = **前 16 天 + 今天 + 后 15 天**(`ganttAnchor = TODAY - 16`):32 天没法在「今天」
两侧各放 16 天(那要 33 天),取整必然偏一天,偏给**过去** —— 已经发生的事实要一直看得见。
这个 32 后续可能搬进设置做成可配,现在不体现。
- **看别的时间段靠拖动**:在表格上按住横向拖动 = 平移窗口,**按天吸附**(拖过半格才挪一天)。
监听挂在 `.tt-box` 上而**不是** `.tt-scroll` —— 后者每次 render 都被换掉。拖动 5px 以内算
点击(否则「选中一行」会变成「窗口挪了一格」),拖过之后的那次 click 在捕获阶段被吃掉。
- **拖远了有出口**:今天不在窗口里时,表头那句区间变成强调色的「↺ 回到今天」,点它复位。
没有出口的话,昨天那批数据可能在屏幕外好几星期。
- **条上只回答时间**:行里那条日期带(`.tt-band`)、条上的范围分段(`.seg-in` / `.seg-out`)、
表头那几格高亮(`.tt-date.in-range`)都撤了 —— 一条上同时压「进度填充 + 范围内外 + 状态底色」
三种说法时,最该看清的东西反而被盖住,用户的原话是「**进度和持续时间混合,导致甘特图本身
可能失去价值**」。最后撤的是表头高亮那几格:它当时是「档位在哪一段」唯一的出口,档位没了,
它也就没有要说的东西了。
- **条 = 时间 + 状态 + 进度**(三件事各占一个通道):**长度** = 时间跨度(创建日 → 截止日,
见下一条);**色相** = 状态(进行中 / 已完成 / 逾期 —— 「待办」那档同日删掉,蓝也随色相
体系撤了);**填充**(`--fill`,条内左段)= **进度**。
⚠️ 这一条**翻过两次面**,两次都是用户定的,理由都留着:
· 2026-09-21 早些时候:**取消进度填充**(原话「进度和持续时间混合,会导致甘特图失去
价值」),改成「标准甘特」—— 位置(时间)与长度(进度)是两个量,叠在一条上读者分不清;
· 2026-09-21 晚些:**又请了回来**(原话「不同进度的感觉差异不大」)—— 因为条上除了
「哪段时间、什么状态」之外**没有第三个通道**说进度,68% 与 4% 的条长得一模一样。
这次的做法是**只填长度、不叠别的**:底(未完成那一段)用同色系很浅的底,填充段是
饱满的**状态色纯色**。位置与色相把前两件事说完了,填充再回答「走到哪了」,三者不抢戏。
⚠️ 同一次还修了一处:**逾期延长段改成半透明**(`.seg-late`)—— 它是实色、又在填充层之上,
截止日在窗口左侧的逾期条(整条都是延长段)会把填充整个盖住,于是那些条看不出进度、
和别的条长得不一样。**进度数字**仍在条内 + 行首那枚**进度饼**
(`progressDot()`)+ 悬停小卡 + 条够宽时的条内白字进度。⚠️ 条上**只写 `N%`、不写标题**
(2026-09-21 用户提的「没必要再显示任务名称」):标题在左边任务列里已经占了一整行,条上再写
一遍是同一句话说两遍,而条的宽度本来就紧张。阈值也随之从 78px 降到 **40px** —— 文字只剩
三四个字符,太高的门槛会让大半条上是空的。
另外它**贴条右端**(`right: 7px`):它在**未完成那一段**上(进度 100% 时才落进填充段),
而底色会在这两种之间切换 —— 所以描边要给足:白字四向各一道细描边,压在浅底上是
「白字带黑边」、压在饱满的状态色上就是普通的白字。
- **逾期:条的右端渲染延长到今天**(`.seg-late` 警示色 + 原结束日处一道 `.late-mark` 竖记)。
**数据里的结束日一个字节都不动** —— 那是事实,这是解释(与 §4.10 那条「完成日 + N」同源)。
不延长的话,逾期与「刚好今天截止」在图上长得一模一样。
- **跨页请求进来时,窗口对准这批任务**(`focusAnchor`)。2026-09-20 用户报的是另一件事(选
「上月」时窗口停在「上月 30 ~ 本月 21」,而任务集中在上月末到本月初 —— 窗口和任务错位,
看着就是「点了像没反应」),但结论是通用的:**窗口要跟着任务走**,不是跟着日历走。规则:
以这批任务**条形的跨度**(最早创建日 → 最晚截止日)**居中**,32 天装不下也照居中;一条任务
都没有 → 回到默认的「今天前 16 天」。只在**请求进来的那一帧**自动挪,之后一律尊重用户的拖动
(首页也不挪了:默认窗口 `TODAY - 16` 就是最中性的起点)。
- **条的时间基准:创建日 → 截止日**(原来用「开始 → 截止」)。创建日才是这条任务**进入视野**
的那天,开始日只是它被排上日程的日子;「我手上这摊活儿从哪天起、到哪天必须交」是这一页要
回答的问题。悬停小卡同步成「创建 X → 截止 Y」。⚠️ 由此**开始日不再影响条形**(字段仍在
表单与数据里)—— 这是有意的取舍,不是漏。
- **两端跨出窗口时贴边 + 打截断标记**(`.tt-bar .cut`),整条都在窗口外时**两侧都打**:
只打一侧会被读成「它从窗口外伸进来、在这里就结束了」。
+ ⚠️ **右端那一夹 2026-10-16 才补上**(`renderRow` 的 `outsideRight = createdDay > winEnd`):
+ 此前只夹了左端(`Math.max(createdDay, win.start)`),于是**创建日晚于窗口末日**的条
+ `left` 会算到 `.tt-track` 的宽度(`win.days * colW`)**之外** —— 日期格线与周末带是铺在
+ 轨道上的重复渐变、轨道又不裁切,那条就飘在一片**没有日期格背景**的空地上,还顺带把表格
+ 撑出横向溢出。用户报的原话是「任务甘特图容易出现进度条与背景范围不同步…即有的任务中
+ 左右滚动时,发现甘特图进度条出现的地方,背景区域无对应的日期表格背景」——「左右滚动」
+ 正是因为那批条被画到了表格右边:得滚动才看得见它们(证伪跑实测 12 条 `bulk-*` 越界
+ `.tt-track` 右缘 54px)。现在两端对称:整条在窗口外 → 贴最近的那一边 + 两侧截断记号。
+ 同一次还收了两处「记号越界」(同一类毛病):**整条在窗口右侧之外时不画逾期延长段**
+ (真实起止整段在窗口外,那段延长会盖满被夹到末列的桩),**竖记 `.late-mark` 只在落点
+ 确实落在条的时间范围内时才画**(真实结束日算到条外时,画出来是一条飘在条外的灰线)。
- **视觉语言(2026-09-21 配色整理;同日条改成标准甘特)**:一张表上最该跳出来的是**条**和
**今天**,坐标系(格线 / 表头 / 周末 / 月首)一律收轻。全部走 `tokens.css` 的变量,
深浅两个主题同一套规则:
· **条 = 标准甘特条**(9px 圆角,与行首那枚圆形进度饼同族):颜色只编码状态,**面是
状态色纯色**(`--bar-state` ← `--doing` / `--done` / `--danger`),里面填的那一段
(`::before`,宽度 = `--fill`)= 进度。
⚠️ **渐变试过、撤了**(2026-09-21):三段色标(浅 → 本色 → 深)在 100% 填充的长条上会
露出**两个折点**,读起来像条被切成几截 —— 用户的原话是「**渐变分层**」,随后要求
「放弃渐变色」。前两版也都不成立:斜向 / 水平会被**长度稀释**(几百 px 走完那点差值),
垂直只有 **19px** 的跨度、根本铺不开。**纯色才是稳的**:色相直接等于状态,进度交给填充
长度 —— 顺带把条与徽标、进度饼、优先级色阶重新对回了同一份色值。
**「进行中」的基色**同日从 `#c07f2d` 换成 `#d98b1f`(用户嫌原来那个「不好看」——
偏黄、饱和度低,确实像芥末;深色主题同步 `#dfa24e` → `#e8ab45`)。它同时是优先级 P1
的停靠点,换掉之后「红 → 亮橙 → 黄绿 → 绿」那条色阶跟着变亮,过渡反而更顺。
条内白字的描边保留 —— 它会压在两种底上(饱满的状态色 / 很浅的未完成底)。
⚠️ **进度数字要抬到逾期延长段之上**(`.tt-bar b` 的 `z-index: 3`):延长段(`.seg-late`,
`z-index: 2`)从结束日一直铺到今天、正好压在条的右端,而进度数字也贴右端 —— 不抬起来,
**逾期条上的进度会被整个盖掉**(2026-09-21 用户报的「逾期的甘特图上没有进度显示」)。
**进度有两种画法**(同日各上过一次,见 §4.2 那条两次翻面的说明):底 + 填充段
(`::before`,宽度 = `--fill`),以及行首那枚进度饼、悬停小卡与条内那道白字进度;
· **周末**:表头那格上底色,行里用「7 天一个周期、宽 2 天(周六 + 周日)」的 repeating 渐变
+ `background-position: var(--tt-wk)` 对齐到窗口里的第一个周六 —— 每格一个节点那种画法
在 32 天 × 28 行下要多出近千个只用来铺色的 div;
· **月首**:表头那格把「周几」换成「几月」并加一条左分隔线,行里同一列一条 `.tt-moline`
(两处指同一根线);
· **今天**:表头一格(底色 + 底线)+ 行里一条 2px 竖线(`.tt-today`),
**只在今天确实落在窗口里时才画**(2026-09-21 用户提的)—— 拖到别的月份之后,一条画在
屏幕外或被夹在边上的线只会误导人,那时的出口是表头那句「↺ 回到今天」;今天贴到窗口两端时
那个「今天」小签贴边对齐(居中的话会伸进任务列、或被吸附列盖掉半截,越过右边界还会把表撑宽);
· **档位那三处高亮都撤了**(行里的日期带 `.tt-band`、条上的分段 `.seg-in` / `.seg-out`、
表头那几格 `.tt-date.in-range`):一条上同时压「进度 / 范围内外 / 状态」三种说法时,最该
看清的东西反而被盖住。表头那几格是最后撤的 —— 它当时是「档位在哪一段」唯一的出口,
而那排档位 2026-09-21 一起撤了(见 §4.2)。
- **别再退回「把窗口撑开到装下所有任务」那种做法**(`fitWindow` 曾经无上限):它会让六个档位
算出同一个窗口、点哪个都像没反应,而且跨月数据会把甘特挤成几百根 15px 的细线。
也**别再按档位把窗口缩到 1 天 / 7 天**:那是「今天 = 一根线」的来历。
- **行首那枚「进度饼」**(2026-09-20 改,`tasks.ts` 的 `progressDot()`):一个圆环 + 按进度从
12 点顺时针长出的扇形(CSS `conic-gradient`,百分比由 TS 写进 `--pct`,样式表里不出现 JS 拼的
样式串)。它取代了原来那枚 8px 的状态圆点 —— 圆点**只有颜色一个通道**,而它表达的是状态,
一个裸色点挂在任务名前面,谁都会以为那是进度(当天就这么被问过一次)。
现在**形状说进度、颜色说状态**,两个通道各管一件事:颜色仍走 `statusVar()`
(逾期红 / 进行中橙 / 已完成绿;历史上的「待办」与进行中同色)。两端各给一个更好认的记号:
**进行中 + 0% → ▶**(还没开始,可以动手)、**100% → ✓**(实心圆 + 白勾)——
勾必须固定白色,跟着 `currentColor` 走会和底同色,等于没画。
+ ⚠️ 两个通道**不再各说各话**(2026-10-16):进度不满 100% 就不可能停在「已完成」,满 100% 就
+ 一定是「已完成」(口径见 §4.10)。于是「红色 ✓」——颜色说逾期、形状说做完——这种组合不会再
+ 出现;它骗过用户一次,也让一轮排查绕了远路(任务因此收不进归档)。**显示口径本身没动**:
+ 仍是形状说进度、颜色说状态,两端那两枚记号照旧。
`data-pct` / `data-mark` 把「画出来的那个值」在 DOM 上留一份可读的:进度现在是图形,
读屏、调试、以及「与悬停提示对不对得上」这类断言都得有个据。
任务列宽度因此 248 → **256**(`.tt-label` 与 `LABEL_W` 必须同步,差 8px 表头那条「今天」
竖线就会偏)。管理页那条进度列的写法则不动:它是「条 + 数字」,本来就看得见数值。
- **任务列两行的分工**(2026-09-20 改,`tasks.ts` 的 `renderRow`):**第一行只放标题**,
第二行是「标签 + 截止 + 优先级」。以前标题与截止共用第一行、优先级徽标另占右端一列,
标题只剩半列宽(约 120–150px),稍长一点就成了省略号 —— 列表里看不出这是哪条任务,
看不出等于没有。截止与优先级都是**短记号**,让它们去分标题的宽度是本末倒置;并到标签
那一排之后,标题吃满整列(207px)。**挤不开时的取舍写在 CSS 注释里**:先缩标签(标签缩掉
一截还看得出是标签),截止与优先级 `flex: none` 永不缺席 —— 它们各占一个固定位置,
被整条裁掉就再也找不回来。截止 `margin-left:auto` 靠右(标签长短不固定,不靠右这一排会
跟着晃),优先级仍钉在**任务列右端**:与行首那枚进度饼一左一右,颜色与位置两个通道不打架。
- **`seg` 是无状态控件**(`shell/seg.ts` 顶部):点了之后**调用方要调 `setValue`**,否则高亮
停在原处 —— 任务页那排档位当年就漏过这一步,「点了像没反应」其实有两层理由(窗口被撑开 +
高亮的是上一个)。现在 seg 只剩总览的焦点时间轴在用,那一处不用补:它整页重建,seg 每次
都是新造的。
- 工具行:搜索、状态与优先级筛选、**排序**、每页条数(那排档位 2026-09-21 撤了,见上)。
**归档筛选在管理页那一页**(只有它显示已归档任务),这里没有。
- **默认排序「按优先级」**(2026-09-21 用户定的,原来默认「按截止日期」)。优先级相同的
再按截止日期排(`comparators.urgency`)——「今天必须交的 P1」不会被埋在一堆 P0 后面。
- **下拉是「点了要切得动」的反面教材**(2026-09-21 用户报的:排序下拉切不到别的选项上)。
`dropdown` 与 `seg` 一样是**无状态**控件,而它原来只有 `setValue` 会改显示 —— 菜单行
自己点了**不同步**:值换了、表也重排了,按钮上却还写着旧的那一项、勾也还在旧项上,
看着就是「切不过去」。现在**菜单行自己同步显示**(`menu.ts` 的 row 回调里先 `apply`
再 `onPick`),`setValue` 留给「**别处**改了值」的场合(跨页请求复位优先级筛选那类)。
e2e 两条:默认是「按优先级」、切两次之后按钮文案与勾选项都跟着换。
- **「今日到期」是一枚独立 chip,不是状态那排的第六项**(2026-09-20):它筛的是**截止日**,
与状态是两个维度。两种筛选**互斥** —— 点亮它就把状态复位成「全部」,点状态那排就把它关掉;
它开着时状态那排**一个都不高亮**(那时列表是「今天到期」那批,不是「全部」,两个 chip 同时
亮着读起来像这次筛选有两层,说不清)。它是总览那张「今日到期」卡的落点:没有它,点进来只
看到 1 条,页面上没有任何地方说得清为什么,也没有出口(它的开关就是出口,再点一下退回全部)。
判据与卡片同源,见 §4.1 的 `isDueToday`。
- 页头只有**一个**动作:`+ 新建任务`(走与编辑同一套的完整表单)。批量入口不在这儿 ——
见下面「数据迁入」。
**数据迁入**(`tasks.ts` 的 `openImportDialog`,入口在任务管理页,与「导出 ▾」并排):
一次把一批任务连同**它们的活动时间线**建出来 —— 也是迁移旧数据的唯一通道。四件事是刻意的:
1. **只有选文件,没有粘贴框**。转换工具的产物本来就是**一个文件**,中间再绕一趟
「打开 → 全选 → 复制 → 切窗口 → 粘贴」只是多几步;而留着粘贴框会让这一屏同时服务
「迁移」和「随手录十条」两件事,名字重新变模糊。实现用 `<input type="file">` +
`File.text()`:不用 `plugin-dialog` / `plugin-fs`,浏览器里也能跑,e2e 能用
`setInputFiles` 真测。
2. **解析出几条、哪几行认不出、哪几条与库里同名**,都要在点「导入」之前看见。
`markdown.ts` 的 `parseTasksDetailed` 就是为这件事拆出来的:解析结果除了草稿,还带
`skipped`(行号 + 原文,空行不算)。跟迁移工具那套「先对账」是同一条规矩 ——
**少掉的东西必须能看见**。
3. **重复只提示、不替用户决定**:判重规则只有一条 —— 标题在**当前分区**里已存在。
同名不一定同一条,所以给「跳过重复」与「全部导入」两个按钮;旧版里改过标题的认不出,
界面上也写了这句。默认动作是「跳过重复」—— 造出重复数据的清理成本比少导几条高。
4. **只告知当前分区**(`导入到「演示空间」`),不猜也不拦 —— 导到哪个由用户自己切好再进来。
旧版本来就是按分区导出的,所以一个文件对应一个目标分区。
这一屏**只留三样**:导到哪儿 / 选文件 / 解析结果。不在这儿讲写法 —— 语法写在
[`resources/skill/tadado-activity-import/SKILL.md`](resources/skill/tadado-activity-import/SKILL.md),
而文件是工具产出的,用户不需要再看一遍。
> 「批量新建任务」这个名字、以及它先后挂在工具行与页头的那两段历史,见 §8.3。
- **换筛选要回第一页**:消费跨页请求时重置页码。不重置的话,从总览点「逾期 38」进来会停在
上一轮翻到的第 2 页,看到的只是后半截。
- 列宽与格线由 CSS 变量 `--tt-col` 统一(TS 一处算、表头与 `.tt-track` 的格线周期都取它),
避免各列自己算。
### 4.3 任务图谱
**需求**:回答「这条任务和什么有关」,而不是再给一张列表。
- 布局是**确定性**的力导向(同一份数据每次画出同一张图,否则「刚才那个节点去哪了」没法回答)。
- ⚠️ **取数含归档**(2026-09-21 用户报的「图谱只显示进行中,已完成的都不会显示」):
默认「完成后归档=立即」(§4.10)之下,做完的任务**当场**就离开 `activeTasks()` —— 图谱上
于是永远只剩「进行中」,那张图看起来像坏了。**归档只该影响任务页**(那页的定位是「手上的
活儿」,`activeTasks` 就是为它写的);其余模块都是**看全貌**的:活动分析与管理页本来就含
归档,图谱跟上(总览那张「已归档」卡也是同一个意思)。取数改成 `partitionTasks()`。
- 节点三类:**分区**(根)→ **标签** → **任务**;连线表示归属与关联。
- 悬停高亮相邻关系,双击任务直达维护抽屉。
+ - **点画布空白收起右上角的详情卡**(2026-10-16 加):节点高亮一起清掉,卡片退回
+ 「选择一个节点」。这一手是**本地**的(`clearDetail()` 挂在 `.gwrap` 的 click 上),
+ 没有放进 `shell/panels.ts` —— 它不是从右侧滑出的抽屉、也不参与「同一时刻最多一块」
+ 那条互斥,只是跟着**选中**走的一块浮层,塞进面板层只会让那一层多认识一个概念。
+ 节点自己的 click 已经 `stopPropagation`(选中即展示,不能同一次点击里又被清掉),
+ stage 上那几块浮动件(`.gdetail` / `.gstats` / `.gpart` / `.gzoom`)逐个豁免 ——
+ 尤其 `.gzoom`,它就在卡片正下方,是最容易被误伤的一个。
- 舞台按可用空间铺开,吃掉主区剩余高度。
- 图例用语与全应用统一:「分区」不叫「根节点」(后者是图论术语,对用户没有信息量)。
### 4.4 活动分析
**需求**:回顾「时间花在哪」,并按标签切分。
- 上半是**整年热力图**(12 个月块,颜色深浅 = 当天活动数),可换配色方案(`data-scheme`)。
- 范围条横跨整页:快捷范围(本周 / 本月 / 本年 / 指定)与年份切换;**选「指定范围」才出现日期框**。
- 点热力图上的一天 = 报告切到那一天。
- 下半是**分标签报告**:标签列表只留当前范围**有活动**的标签,不解释被隐藏的;`◀ #标签 ▶` 翻页
不会翻到没活动的标签。条数文本排在**标签切换组外面**(它数的是当前标签在当前范围的条数,
挂在标题旁会被读成「整份报告共几条」)。
- **「全选」是一枚开关,不是两个单向按钮**(2026-09-20 定稿):默认全选 → 用户可以逐个调整
勾选 → **再点一次 = 取消全选**。原来那枚「清空」撤掉了。
起因是一句反馈:「清空好像没用,全选再点一次就取消全选了、和清空一样」。实测(一次性探针,
依次点:初始 → 全选 → 全选 → 清空 → 全选):当时的「全选」**只 `add`、从不 `delete`**,
并不取消勾选 —— 但它在默认状态下**永远是空操作**,因为默认勾选集就等于「当前范围内有活动的
标签」(也就是左侧列表里那几个)。所以真正的病根不是「它和清空重复」,而是**那个按钮在默认
状态下没有事可做**:一个点了没反应的按钮,只能引出两种结论 ——「它坏了」或者「它和旁边那个
是一回事」,而这两句出现在同一条反馈里。
开关的形状把病根去掉了:**两边都有活儿干,点下去一定有变化**。代价写清楚:清掉「部分勾选」
要点两下(先全选、再取消全选)。状态画在颜色上(`.on` = 强调色):全勾着时按钮是亮的 ——
那一亮就是「现在是全选状态」的说明,不必再拿一个文字标签去讲。只有列表里一个标签都没有
(这个范围里没有标签有活动)时才置灰 —— 那时两边都没事可做。
e2e 照开关的语义验三段:默认全选(亮)→ 点一次全清(**报告跟着空**,不是「看着没勾、
其实还在筛」)→ 再点一次全回来。(中间试过「两个按钮 + 没活儿干时置灰」那一版,最后按
这个方向收口。)
- 导出与范围同行,独立按钮置最右。
### 4.5 任务管理
**需求**:批量处置与标签维护。这是**唯一显示已归档任务**的视图。
- 表格可多选,批量动作:**标记完成 / 归档 / 取消归档 / 删除**(删除走二次确认)。表头有
整页勾选框。(这里曾写成「改状态 / 延后 / 中止 / 删除」—— 延后与中止在桌面端**根本不存在**,
是从 v0.x 的特性清单里抄过来的。)
- **「归档」那一列是只读的**(与「状态」「标签」两列同性质):只摆标 —— `已归档` / `—`
(破折号那格悬停补一句「未归档」)。**曾经在这里放过一枚单条「归档 / 取消归档」按钮,撤了**
(2026-09-21 用户定的:「只显示归档标签即可,类似于『标签』或者『状态』」)—— 那一列是**看**
的,动手的地方是页头那枚**按筛选**的按钮,以及**勾选之后**的批量栏;单条处置就走「勾一行 +
批量栏」,与这一页其他批量动作同一条路。
**已有数据交给用户自己处置**:不替老库做决定(见 §4.10 的一次性冻结),收哪几条由这两处说了算。
- **页头「导出」之后是一枚批量归档按钮**(2026-09-21 用户提的「在导出之后增加一个归档按钮
即可」):「批量筛选」这件事表格上那两个筛选(状态 × 归档)已经做完了,这一枚只负责**执行**
—— 按**当前筛选(跨页)**归档 / 取消归档。所以它**不需要**先勾选,标签里也**不写条数**
(条数是筛选的产物,写进按钮等于把同一件事说两遍)。动作与标签都跟着**归档档位**走,
判据是「**不全是已归档**」:只有停在「已归档」那一枚时是「取消归档」,其余(「未归档」,
以及点状态那几枚时的 `"all"`)都是「归档」—— 一次只可能做对该做的那件事。
⚠️ 写成 `archiveFilter === "active"` 是错的:点状态那几枚 chip 会把归档维打回 `"all"`,
于是**默认进这一页按钮上就挂着「取消归档」**,与眼前这批对不上。
走二次确认(标题里写明条数),恢复的那条路同样 `keepActive`;筛出 0 条时给一句提示,不静默
无反应(那正是这一页最容易被当成坏了的样子)。
(中间试过一版「常驻批量条 + 按钮上写条数」,撤了:「勾选」与「按筛选」是两种输入,
挤在同一条里只会让人以为必须先勾选。)
- **筛选是**一条**单选**(2026-09-21 用户定的):`全部状态 / 逾期 / 进行中 / 已完成 /
未归档 / 已归档` —— **每一枚 = 一个视图**,永远只亮一枚,默认「全部状态」。以前是「状态 ×
归档」两组 chip,各自单选却挨在一起 —— 看起来就是一条单选行里亮了两枚,于是被当成坏了
(用户那句「默认怎么不是全部状态」就是从这儿来的)。**「待办」那枚同一天撤了** ——
状态本身删了(见 §4.1),它和「进行中」本来就是一回事。
合单的规则:状态那几枚**不筛归档**(两侧都算,哪条是哪条看「归档」那一列),归档那两枚
**不筛状态**。内部仍保留 `archiveFilter` 的第三态 `"all"` —— 它正是状态那几枚在用的。
**代价说清楚**:组合筛选表达不出来了(「已完成 × 已归档」这种问法没有入口)。换来的是
这一页的筛选一眼就懂。
- 标签管理:选中标签可重命名,**改为已有名称即为合并**。
- 导出跟着**当前筛选**走(表格上摆着状态与归档两个筛选,导出的就该是眼前这批)。
- **「数据迁入」与「导出 ▾」并排**:一进一出。它导的**不是**上面那种给人看的清单(那种本来就
回不来),而是**任务行写法** —— 也是唯一能把活动历史一起带进来的通道(见 §4.2)。
+ - 「数据迁入」的对账因此有**三块**:认不出的行、与库里同名的、**起点被按截止日修正的**
+ (2026-10-16 加)。最后这块是这页里唯一**替用户动过数据**的一处,所以它必须像另两块一样
+ 在点「导入」之前逐条列出来(`.batch-adj`,最多 5 条 + 「…另有 N 条」,与另两块同一套排布;
+ 导入后的回执也会提一句):md 里只写截止日,**起点与创建日是解析器推断的**(最早一条活动的
+ 日期,见 §4.8),推断出来的起点晚于截止日时,两者一并落到截止日 —— 截止是源里写着的、
+ 最早那条活动也是源里写着的,**两个事实一个都不动**。反过来的做法(把截止顺手推后)等于
+ 修改用户写下的截止时间,没人授权。手写的行也会碰到这一条:它们没有活动行,起点默认是
+ 「导入日」,而**手写的过期任务**(截止写在过去)正是「起点晚于截止日」的典型。
### 4.6 维护抽屉与定义表单
**需求**:一条任务的全部信息与它的历史,一处看完。
抽屉分两段:上面「**任务定义**」(这条任务**是什么**),下面「**活动时间线**」(它**经历过什么**)。
以前这两串字段与记录平铺在一起,中间没有任何边界。
- 改状态、改进度、改优先级都会**写一条活动记录** —— 变更留痕是这套数据模型的核心,
否则「本周完成」这类统计只能靠状态字段倒推,改一次状态就丢一段历史。
- ~~**写一条进展 = 这条活儿动起来了**~~(2026-09-21 加,**同日晚些撤掉**):它当年做的是
「写第一条记录 → 把待办推到进行中」,理由与实现都成立;但那之后**「待办」这一档本身删了**
(见 §4.1),新建任务就是「进行中」—— **没有可推的起点**,那段逻辑随之撤掉。留下的只有
活动记录里那些 `from: "todo"` 的历史,时间线照旧显示「待办 → 进行中」。
- 抽屉底部**没有删除与保存按钮**:改动即时生效(删除在右键菜单与表单里),
没有页脚就没有「我改了但没保存」的状态。
+ - **换一条任务 = 先滑出完、再滑入新的**(2026-10-16 用户提的:「查看第一个任务时点
+ 第二个,先关闭第一个的滑窗,然后才是第二个的弹窗,**而不是原地更换内容**」)。原来
+ 就是在同一条抽屉 DOM 上原地换内容:动画走的是同一个元素,前一条的内容跟着一起滑进
+ 滑出,看上去只是抖了一下,读不出「换了一条」。判据在 `openTask`:抽屉**开着**且看的
+ 是另一条时,先摘 `.open`、等 `transitionend`(`PANEL_LEAVE_MS` 兜底 —— 减少动效、
+ 元素被移走、上一次过渡没走完就被改回原值这三种情况下过渡事件都不会来)再 `paint`
+ 新任务并滑回来。三条约束:
+ ① 滑出期间 `openedPanel()` **仍然是 `"task"`**。这样「点面板外」「开设置抽屉」
+ 「切页」「Esc」走的都还是既有那几条路,而它们最后都会落到 `closeTask`,由 `closeTask`
+ 把这一手撤掉;反过来,滑出时就把面板标成「已关」的话,这几条路全都拦不住这次滑入
+ (设置抽屉之上会再滑出一块任务抽屉,互斥就破了)。
+ ② 待执行的滑入**必须可取消**:连点第三条任务只换目标、动画不重头再来(每点一下都
+ 重新滑一次,看着像在原地抽搐);`closeTask` 里的取消要放在最早一行 —— 那一刻 `.open`
+ 已经摘了,正是最容易被误判成「没开」而提前 return 的时候。
+ ③ 这段状态机只能放在**页面层**:A 与 B 是同一个 `PanelId = "task"`,`panels.ts`
+ 分辨不出「换了一条」。设置 ⇄ 任务之间的互斥没有跟着改(两块仍然是同时动),
+ 本次只要求任务 A→B 的先关后开。
- 记录可编辑、可删除,编辑过的标出「已编辑」。
- 底部是 **Markdown 源**(可折叠):敲 md 就地渲染成标题 / 标签 / 截止 / 进度,
点「按 md 更新任务」才写回。**这条写回路径不采纳状态**(见 §4.8)。
- 新建走页头的「+ 新建任务」,打开的是**和编辑同一套表单**(`taskForm.ts`)。
- **新建时「结束」默认今天 + 当前时刻**(`draftTask()`,2026-09-21 用户提的:「时分默认是
当前时间,目前是空,需要手动选择」)。两件事一起修:时刻原本是空的(点完「今天」还得再挑
一次时分,而绝大多数新建就是「从现在起」);日期框本来就显示着今天,模型里的 `due` 却是
null —— 字段**看着填好了**、点「创建」却说「还差:结束时间」,显示与模型两个口径。
现在 `due` / `at` / `end` 一次给齐。「结束时间是必填」这条规矩仍然成立:点「清除」之后
`due` 就是 null,创建时照样拦下来(e2e 两条分别钉住:默认值、以及清掉之后被拦)。
+ - **起止区间不许倒挂:结束不得早于开始**(2026-10-16 加,**同日合法** —— 开始的当天就是
+ 截止日是最常见的排法之一,种子里的「健身房训练」「晨会记录」「晨跑 5 公里」都是
+ `start === end`)。判据只写一处:`data/time.ts` 的 `isRangeInverted(startDay, endDay)`
+ = `endDay < startDay`(写成 `<=` 就把同日错杀了)。
+ **拦截点也只有一处:日期控件**(`shell/dateTime.ts` 的 `bounds` / `peerLabel`)。两栏互为
+ 对方的边界 —— 原生 `min` / `max` 把越界的那一天在取色盘里灰掉,提交前再判一次兜住手输与
+ 粘贴;越界时**不触发 `onChange`**(模型一个字节都不动)、显示退回原值,并 toast 说清是被
+ 哪一端卡住的(「开始不能晚于结束(10-16)」)。**为什么不在各页面自己判**:抽屉是
+ **即时落库**的(`onEdit: () => dataChanged()`),拦在出口之前才不需要「先写坏、再回滚」——
+ 那条路上任何一个岔子都会在库里留下一个倒挂区间;而且新建对话框与抽屉共用这一套控件,
+ 两处表现天然一致(判据要是抄两份,迟早一边漏)。
+ 两处涟漪一起处理:① 结束栏「清除截止」原本把 `end` 落回**写死的今天**,开始日排在将来时
+ 会当场倒挂 → 现在落回「今天与开始日里**较晚**的那个」(同时保住「清除后不再算逾期」这条
+ 既有承诺);② **没有截止时 `end` 只是必填的占位日**(界面上不显示「未结束」),所以
+ 「开始」那一栏此时**不设上界** —— 拿一个看不见的占位日卡住开始,等于不让人给没有截止的
+ 任务排将来的起点;开始越过占位日时把它**一起带走**(模型里始终 `end >= start`)。
+ **历史数据不处理**(2026-10-16 定的):读入口不纠正、不写迁移,库里已有的倒挂区间照旧
+ 显示;但**只改时分不会被拦** —— 判据只在**日期真的被改动**时生效,否则那些老任务的时刻
+ 就永远改不动了。同一处顺带收掉一个老洞:「开始」栏用键盘删空原本会显示成「未设置」而
+ 模型还留着旧值(显示与模型两个口径),现在按「必填项不给清」退回原值 —— 与「不摆
+ 『清除』按钮」是同一条理由。
**进度就在活动时间线里,草稿式提交,而且一律手动维护**(2026-09-20 用户提的):写一句进展、
把进度挪到哪,本来是同一次动作的两半 —— 以前一个在「任务定义」(滑杆 + 数字框)、一个在时间线
的输入框,用起来必然漏维护一处。现在:
- **控件搬到活动时间线**(`.tl-prog`,就在「记录一条进展」那一行上方),任务定义里不再重复
一份(抽屉传 `taskForm({ withProgress: false })`)。控件抽成 `taskForm.ts` 的
`progressControl()`:对话框用 `mode: "immediate"`(那边没有「发送」按钮,表单本来即时保存),
抽屉用 `mode: "deferred"`。
- **左边那条就是滑杆本身**:带填充 = 一眼看出到哪了,拖一下就是改;右边数字框给精确值。
不另摆一条只读进度条 —— 两条长得一样,摆在一起只是重复。
- **拖动不落库,按「发送」才保存**(同日修的两个 bug 之一:「进度条调整后,还没来得及编辑
信息,已经被提交了」)。deferred 模式下拖 / 填只改**草稿**,控件把它写在脸上
(`.prog-pending`「待保存 65% → 70%」)—— 不写出来,用户拖完就走,改动静默消失,那是同一
类毛病的另一面。光改进度不写说明时不保存,给一句提示并聚焦输入框。
- **说明与进度落在同一条记录上**:提交时那句说明就是这条进度记录的 `text`(`commit(text)`)。
另起一条 `log` 等于又把信息与进度拆开,那正是这次要修的毛病。
- **进度记录可以再编辑**(同日修的第二个 bug:带说明的进度记录长得像 log,却是
`kind: "progress"`,当时没有编辑入口)。编辑能改说明**和值**,但值有上界:
`progressEditBound()` = **后面(更新的)那条进度记录的 `to`**(它自己是最新则到 100)——
时间线是**单调**的,把老记录改到超过后来的值,页面上就会出现「先 80、后 70」这种倒着走的
进度,而两条都是「真的」,这条时间线就不再能读。改完把**后面那条的 `from` 跟着挪**
(链条必须接得上);改的是最新那条时,`task.progress` 一起更新。删掉最新那条进度记录时
同理:任务的当前进度退回它的 `from`。
- **不做任何自动推导**:用户用了很多次之后明确定过「**进度手动维护是最好的设置**」——
拖到哪、填多少就是多少。
**时间线的读法:正序(追加模式)**(2026-09-20 用户提的「倒序读起来别扭」):最早的在上面,
最新的贴着下面那一行输入框 —— 这本台账是往后写的,读到最底下正好接着「记录一条进展」。
- **存储顺序不动**:`activities` 仍然**最新在前**(`data/markdown.ts` 里写明的那条约定,
导出、统计、以及「后面那条记录」这个上界判定都依赖它),只在 `renderTimeline` 里反着铺。
- 因此**就地编辑不能再按 DOM 下标取那条记录**(渲染下标与数组下标刚好相反),卡片引用是
直接传进去的。
- **就地编辑区是两行**:第一行整行给「说明」,第二行才是进度值与保存 / 取消。以前全挤在
一行里,说明输入框只剩一百多像素(用户:「再次编辑时信息编辑区域太小了」)。
### 4.7 分页与跨页定位
- 三张表 + 一张活动流共用 `pager.ts` 与同一组档位(20 / 30 / 50 / 100)。档位**不进设置** ——
它是「这次看多少条」,不是一项偏好。**默认值各自不同**:三张带日期列的表 20,总览近期活动
**50**(`FEED_PAGE_SIZE = FEED_LIMIT`,2026-09-20;那张卡问的是「最近发生了什么」,20 行
只够看半天)。
- **「每页几条」与「上限」是两件事**:总览近期活动另有 `FEED_LIMIT = 50`(只摆最近的 50 个
任务,`renderFeed` 里在排序之后截断)。默认档位等于上限,所以平时**只有一页**;档位调到
20/30 时是在**这 50 条以内**翻,翻不到第 50 条之外去 —— 「看更早的」归活动分析页。
- **单位与「报不报总数」都由调用方给**(`pager({ unit, showTotal })`,2026-09-20):
总览的近期活动是按**任务**聚合的 —— ① 用默认的单位「条」会和同一张卡里的「共 N 条」
(某个任务的活动条数)串味,所以那张表传 `unit: "个任务"`;② 那句「共 88 个任务」会被
读成「这一屏显示了 88 条」(而 88 是聚合后的任务总数),所以它再传 `showTotal: false`,
只报范围「第 1–50 个任务」。**空表时两种写法都只写「共 0」/「暂无」**,不写「第 1–0」——
那不是一句人话。**档位下拉只写数**(`50/页`):单位由紧挨着的那句范围说明给,说两遍更挤。
- **只有一页时不摆翻页件**(2026-09-20 用户提的「分页样式繁琐」):`◀ 1 / 1 ▶` 一个都按不动,
却和真正要读的范围说明一样显眼。这一段同时收紧了间距(10 → 6)与档位下拉的外观 ——
它是次级设置,在分页器里不该长得像主按钮。
- ⚠️ **下拉在下方装不下时要向上翻**(`menu.ts` 的 `openPanel` + `.menu.up`,2026-09-20 用户
报的):分页器贴在卡片底部,而这几张卡的正文是 `overflow: hidden`(撑满场景的硬要求),
菜单默认向下展开会落在裁切线以下 —— **看得见一截、点不到任何一项**。判据只看视口:
下方放不下整块菜单且上方更宽裕就翻上去。e2e 量两件事:菜单整块落在卡片**以内**、
点一项真的生效(`分页器的档位下拉向上翻`)。
- 跨页请求走 `focus.ts`:**单向请求 + 单点消费**。「点总览的数字跳到任务页」这类动作只表达
「要去看这条 / 这一批」,由任务页自己决定怎么落地(排序、筛选、选中、高亮);
反过来让总览去操作任务页的内部状态,两页就耦死了。
- **请求要把「能把目标挡住的东西」一起复位**:搜索词、页码(当年还有**档位** —— 它非「全部」
时只列该范围里动过的任务,从总览点「逾期 40」进来只会看到本周动过的那几条;那排档位本身
2026-09-21 撤了,所以现在只剩前两样)。数字都没算错,但看着就是对不上。
控件是常驻的,别处改了值要 `setValue` 把显示跟上:seg 与下拉都是**无状态**控件,
自己不知道数据被别处改过。
### 4.8 Markdown 写法(与 Py 版的主要分叉点)
一行一条任务,是这个应用与外界交换数据的格式:
```
- [ ] 标题 #标签 ⏰09-21 14:30 :: 40%
- 09-20 08:15 记一条进展
```
| 写法 | 含义 |
|------|------|
| `[~]` / `[x]` | 状态:进行中 / 已完成 |
| `[ ]` | **也认**(老文件与别处导出的清单里都是它),读进来算「进行中」—— 「待办」那档 2026-09-21 删了。⚠️ 代价:往返会把 `[ ]` 写成 `[~]` |
| `#标签` | 标签,最多 3 个 |
| `⏰MM-DD [HH:MM]` | 截止;不写 = 无截止(年份按离今天最近的一年推算) |
| `:: 40%` | 进度 |
| 缩进 ≥2 格、`MM-DD HH:MM` 开头 | **活动行**,挂到上一条任务的时间线上 |
| 行尾 `+1w` 之类 | 旧版遗留的循环标记:字段已删,但解析时仍要**吃掉**它(否则会粘进标题) |
**与 Py 版的分叉**:那边方括号里装的是**优先级**(`[***]`)、日期写在 `<>` 里。两版写法**不通用**。
**活动行为什么存在**:活动分析导出的清单是「标签 → 任务 → 活动」三层,它是迁移旧数据时**唯一
带着活动历史**的载体 —— 不带这一条,导进来的任务就只有标题,几十上百条流水全丢。
**状态的一处取舍**(这条最容易被误读):这套写法**是**带状态的 —— `taskToMarkdown` 会写出 `[x]`、
`parseTasks` 也会读它,**批量新建正是靠它带状态**。**不采纳**状态的只有抽屉那条写回路径
(`taskDrawer.ts` 的 `mdApply` 只取标题 / 标签 / 进度 / 截止,状态保持原值,toast 会说出来)。
改状态请在抽屉的表单里改,别指望改 md 那一行。
+ ⚠️ 2026-10-16 补一句边界:**「不采纳」的只是那个复选框,不是进度的语义** —— 进度与状态是同一个
+ 口径的两面(§4.10),所以 md 里写着 `:: 100%` 时,写回之后状态**会**跟着变成「已完成」,toast
+ 照实说。**解析**那一侧做得更彻底:方括号与百分比打架时**复选框优先**(`[x] :: 40%` ⇒ 已完成 +
+ 100%,`[~] x :: 100%` ⇒ 已完成),解析出来的形态与界面里的一致 —— 不然导进来那条会被下一次
+ 自愈扫描当场翻掉,看着像「导入完又自己变回去」。
+
+ **不写回的还有一处:倒挂的截止日**(2026-10-16 加)。md 里的截止日早于这条任务的**开始日**时,
+ 这条截止(`due` / `at` / `end`)**保持原值**,标题 / 标签 / 进度照旧写入,toast 把两个日期
+ 分别说清楚。理由与「状态不采纳」同源:md 在这里本来就是**有选择地**被采纳的;反过来挪开始日
+ 去迁就它,等于改掉一条用户写下的历史,比拒绝更糟。**迁入那条路的选择不一样** —— 那里没有
+ 「原值」可言,起点又本来就是解析器推断的,所以是**按截止日修正起点并逐条列出来**(见 §4.5),
+ 而不是把整条丢掉。
+
> 本文档的这一段曾经写反过(说「状态不写回 md,所以导出再导入会把已完成变成待办」),
> 与代码不符。真实情况如上。
### 4.9 导出
一处定义三种格式,两个用到导出的页面共用(`export.ts`):
- **md 与 txt 内容一致**(同一段文本,只有扩展名不同,排版与 Py 版对齐),xlsx 是同一批数据的表格形态。
- 结构是三层:**标签分块 → 任务(有序)→ 该任务的活动(无序)**。管理页按**第一个标签**分块
(一条任务只出现一次 —— 这是任务清单,同一条出现两遍会让人以为有两件事);活动分析那边按标签
- 查活动,天生会重复,所以那边去重。
+ 查活动,天生会重复,所以那边去重。(「有序 / 无序」说的是 **md 的列表类型** —— `1.` 与 `-`,
+ 不是时间先后。)
+ - **活动行按时间升序**(2026-10-16 用户定的「活动信息导出中时间按照升序显示」):同一个任务下的
+ 几条进展从早读到晚 —— 这是一份要存档、要转手的台账,倒序读起来是「先看到结果、再看到原因」。
+ 任务**分组**的顺序不动(按最近一次活动在前),页面上的报告 / 时间线也仍是**最新在前**(那是
+ 「眼下什么情况」,与文件读者要的不是一件事)。两处导出同一口径(`activity.ts` 的 `queryGroups`
+ 与 `manage.ts` 的 `exportGroups` 各翻一次,md / txt / xlsx 三种格式同时生效)。
- md 里的标签行写 `#标签`(**`#` 后不留空格**):留了空格 md 就把它当一级标题渲染成一行大号字,
比任务本身还抢眼;不留空格它只是一句普通文本,仍一眼看得出分块的开头。缩进用 **3 空格**
—— 正好对齐 `1. ` 的内容列(缩 2 格会变成「懒惰续行」,缩 4 格直接变代码块)。
- txt **一个 md 符号都不用**(`【标签】` / `1)` / `·`):它的用途就是丢给不认 markdown 的地方
(记事本、工单),在那里 `# 标签` 会被原样显示、`- ` 是一串小横杠。
- **不加 BOM**:任务行文本一旦带上 BOM,首行 `- [ ]` 前面就多一个看不见的字符,
再粘回批量新建框时那一条会解析不出来(看起来像「第一条丢了」)。
- xlsx **自己生成**(zip store + CRC32 + `inlineStr`,无第三方依赖):为一个按钮往依赖里塞
几百 KB 不值当,而它是用户明确要的格式。e2e 里**逐项校验 CRC** —— 下载成功不代表包是好的,
CRC 写错时只有 Excel 会报「文件已损坏」。
- 所有时间戳在导出时换算成**绝对日期**(「今天 / 昨天 / 刚刚」这类相对词一律落成日期)。
界面上写「今天 15:00 / 昨天」是对的(更好读),但那是**会变**的:今天导出的「今天」,
明天打开就指错了日子,而文件要存档、要发给别人。
**日期要从数据取,不能从文案取**(2026-09-20 修,管理页 xlsx 的「截止」列曾直接写
`task.due`,于是「今天」进了文件):日期读 `end`、时刻只有 `due` 文案里有(模型没有
单独的截止时刻字段),见 `manage.ts` 的 `exportDue` —— 与 `markdown.ts` 的
`taskToMarkdown` 同一条规矩。**反过来按 `due.includes("今天")` 倒推日期的那种写法
已经炸过一次**(导出过 `⏰2026-明天`,见 `time.ts`)。e2e 逐格读 xlsx 的 D 列来验:
它用 `inlineStr` + 不压缩的 zip 存字符串,单元格原文就躺在字节里,不用解压也读得到。
- **落文件前先问路径**(2026-09-20,`shell/download.ts`)。桌面端弹**系统「另存为」**
(`@tauri-apps/plugin-dialog` 的 `save()`,默认名就是那份文件名、类型只给当前这一种),
用户在对话框里选哪就写到哪 —— 以前的写法是 `<a download>`:桌面端它**不问一句**,
直接落进 WebView 的默认下载目录,于是「导出成功」之后还得去找文件在哪。浏览器
(`vite preview`、e2e)没有宿主对话框,仍走 `<a download>` —— 那里的「下载」本来就是
浏览器自己的事,也能在 e2e 里验。
**一个副作用必须处理**:fs 插件的作用域默认是**空的**(`tauri.conf.json` 里没有
`plugins.fs.scope`),拿对话框给的路径直接 `writeFile` 会被 `path forbidden` 挡下 ——
挡掉的正是用户自己刚点的位置。所以写之前先由 Rust 命令 `allow_save_path` 把
**这一条**路径放进作用域:只放这一条、不放大整个目录,进程退出即失效。换成在配置里
开一片通配作用域(`$HOME/**`)也能写,但那等于把「整个用户目录可写」永久挂在应用身上,
而这里要的只是「这一次选中的那个文件」。取消对话框时**一个字节都不写、也不弹提示**
—— 点了取消还要被提示一次,等于没让人取消。
### 4.10 分区与口令
- 分区是数据的**隔离边界**:任务属于某个分区,rail 底部切换,切换即换一批数据。
- **「我在哪个区」不用悬浮就看得见**(2026-09-20)。改之前 rail 底部那个切换器只有一个文件夹
图标,当前分区的名字只在它的 `title` 里 —— **要悬浮才知道自己在哪**。现在:
① **图标下面写着分区名**(rail 是 64px,一行放得下四五个汉字;超长截断,全文仍在 `title`
里),口令状态画在**图标那一行**(11px 的开锁 / 闭锁);
② 弹层里当前项带**勾选标记** —— 不能只靠底色,底色同时被 `:hover` 用着,扫一眼分不出
「当前项」和「鼠标正停在这一项上」;每项还带**条数**(选之前就知道那一区是不是空的)。
锁分三态,按「用户需要知道什么」划:**不画** = 没设过口令(给没口令的区画一把锁,读起来
像「它锁着」)、**开锁** = 有口令且本次已解锁(提醒它空闲后会自己锁上)、**闭锁** = 有口令
且锁着(正常看不见 —— 锁屏盖着整页;从锁屏进设置重设口令时 `body.lock-suppressed` 让外壳
露出来,那时才可见)。
**锁的位置改过两次,两次都是「尺寸」问题,值得记**:先是挂在图标角上的 13px 角标 —— 那个
尺寸下认不出是锁,看着就是个方点;挪到名字旁边又把名字挤成「演示空…」(56px 的名字行再
加一把锁就超了)。最后落在图标那一行:这行有富余,锁能画到 11px。
页头一度也有过一枚「分区章」,后来撤了 —— 夹在标题与操作按钮之间像贴上去的;分区显示
放在 rail 上(常驻,且切换器本来就在那儿)。总览的问候条里**也**写分区名:那一行是
「今天」的**一句话总结**(日期 · 星期 · 分区 · 条数),少一项就不成句 —— 它和 rail 上
那处不算重复:一个是「我在哪个区」的常驻状态,一个是这一天的概述。
- 口令按分区设、空闲若干分钟无输入自动重锁。**它是隐私屏风不是加密保险箱** ——
挡的是路过的人瞄一眼,不防拿到数据文件的人,所以**忘记口令没有找回入口**,重设即可。
- 口令的设 / 改 / 清合成**一个入口**:按钮文字恒为「设口令」,设没设靠**点亮**表示。
以前按有无口令在「设口令」与「改口令 + 清口令」之间换按钮,用户每次都得先判断自己处在哪种状态,
而答案只有他自己知道。点开是同一个弹窗:填新口令 = 换一个,留着不填 = 不改,`清除`(danger,靠左)。
清口令**不再二次确认** —— 弹窗里那个按钮就是明确动作,口令随时能重设,没什么会因此丢掉。
- 锁屏与设置抽屉的层级:**抽屉压在锁屏之上**,侧栏仍被盖着(忘了口令要能进设置重设)。
- **完成后归档**(kv 的 `archive.afterDays`)是**分区属性**,收在分区行尾的「**自动 ▾**」里
(默认收起 —— 四项分区各挂两个常年不动的 seg 会把设置页挤满)。**名字与档位都是 2026-09-21 改的**:
**完成后:不归档 / 立即 / 7 天 / 30 天 / 90 天**,默认**立即**。
- 旧名字(「自动归档」,第一档叫「关」)两处都错位:「自动」说不出**什么时候**自动(7/30/90
也是自动),而「关」是**功能开关**、不是这条延迟刻度上的 0 —— 0 天那格的意思正是「立即」。
- 判据 = **完成日 + N ≤ 今天**,即「**已完成的任务在任务页存活 N 天**」;「立即」= 0 天
(完成的那一刻就收走)。**为什么用完成日而不是结束日**:旧规则建立在「结束日只可能晚于
完成日」上,而这在「拖到最后才做完」时不成立 —— 于是两个方向都反(截止 09-01、今天才做完、
7 天档 → **当场收走**;截止 12-31、今天做完 → 要赖到明年)。该等的是「**你完成之后**」,
不是「截止之后」。完成日走 `completedOn()`:取 `setTaskStatus` 写的那条 `status → done`
活动(没有则退化为结束日)。它原来在 `pages/overview.ts`(总览「本周完成」在用),归档判据
是第二个消费方 → 挪进 `store.ts`。旧注释写「解析函数在 pages 层,store 够不着」,那是
**位置**问题、不是**依赖方向**问题,挪一次文件就解决。
- 编码:`0` = 不归档(系统永不自动收,只有手动归档才动它)、`-1` = 立即、`N` = 完成后 N 天。
**负数是「立即」而不是「不归档」**,因为老版本里 `0` 就是「关」—— 沿用 `0 = 不归档` 才读得懂
老数据;只有**没设过**的键才走新默认。读档那一步必须用**允许负数**的校验(`asArchiveRecord`,
见 §3.3):这一路的合法值包含负数,通用的数字校验会把它当脏数据丢掉。
- 改档位会**立刻扫一遍**(拨完开关当场能看见哪些任务被收走);手动「取消归档」的记在 `restored`
- 里,本次会话不再被自动归档碰(`keepActive`)—— 否则刚恢复就没了。
+ 里,本次会话不再被自动归档碰(`keepActive`)—— 否则刚恢复就没了。进度拖满导致自动完成、
+ 却因此没收走时,抽屉的 toast 会点明是这一条挡着(`isKeptActive`):这是「明明做完了却没
+ 归档」的**第二种**来路,不说清楚就会被当成同一个 BUG 再报一次。
- ⚠️ **重算的时机与「逾期」一样**(2026-09-21 用户报的「已完成没有立马归档」):`dataChanged()`
里两件事一起做,每次变更都重算。以前只在**启动**与**改档位**时扫过,于是用户点完「已完成」
什么也没发生 —— 看起来像这个设置不生效(其实生效了,只是要等下一次启动)。默认「立即」之下,
这一步正是「勾完即离开任务页」的实现。
- ⚠️ **老库一次性冻结**(同一天):默认改成「立即」之后,**没设过**的分区会拿到「完成即归档」——
对一个用了很久的库,那等于开机把历史上所有已完成的任务一次收走(用户看到的是「我的任务少了
几十条」,而他并没有改过任何设置)。所以 `bootStore` 在**库里本来就有数据**时,把当时缺席的
分区**显式写成「不归档」**(= 它原来的行为);**新装**(连同演示空间)才拿到新默认「立即」。
⚠️ 新装那条路必须把「立即」**落盘**,不能只靠读取时兜底(`archiveDays[id] ?? ARCHIVE_NOW`):
不写盘的话 `archiveDays` 一直是空的,**下一次启动就会被上面这条当成「从没设过」而冻成「不归档」**
—— 新装用户第二次打开,设置里的「完成后归档」自己就变了个样。
+ - ⚠️ **进度与状态是同一个口径的两面**(2026-10-16 用户报的「进度满了却没进归档」)。判据一直是
+ `status === "done"`(上面那条),而「把进度拖到 100%」当年**从来不碰状态** —— 于是一条
+ 「结束日已过 + 进度 100%」的任务被 `refreshOverdue` 标成「逾期」之后**永远收不进归档**,
+ 行首还挂着那枚「红色 ✓」(颜色说状态、形状说进度,见 §4.2)。用户实测反证:手动点一下
+ 「已完成」就当场归档 —— 缺的正是这一步连接。现在这条口径收敛成**一处**:
+ - 规则本体住在 `data/status.ts`(`coreSetStatus` / `syncStatusForProgress` /
+ `rollbackProgressForStatus` / `setProgress`),全是**纯函数**:不广播、不落盘、互不调用、
+ 幂等。放数据层是因为 `store` 的自愈扫描要复用同一份 —— 与 `completedOn`、时间基准同一条
+ 理由(`data/` 不许反向依赖 `pages/`)。`pages/shared.ts` 原样转出,四个既有调用点
+ (任务页右键 / 总览勾选框 / 抽屉状态按钮 / 管理页批量)一行都没改。
+ - **所有能改进度的入口都走 `setProgress()`**:抽屉滑杆与数字框(`progressControl.commit` 是
+ 共用提交口)、删除最新进度记录时的回退、就地编辑进度记录、md 写回、md 导入解析。少任何
+ 一处,那个入口就会重新造出「100% + 非已完成」。
+ - **反向也要退**:状态从「已完成」改回未完成时,进度退到「**完成前那一格**」(活动时间线里
+ 最新那条「值 < 100 的进度记录的 `to`」,一条都没有就归 0)。不退的话,同一次 `dataChanged`
+ 里的 `syncStatusForProgress` 会立刻把它改回「已完成」—— 那个菜单项就成了空操作。
+ - 抽屉是 **deferred**(点「发送」才落库),所以「拖满 ⇒ 完成」发生在**发送那一刻**;待保存
+ 提示在满格时会补一句「发送后自动标记为已完成」,免得用户拖完就走、什么都没发生。新建
+ 对话框是 `immediate`,那里拖满即生效。
+ - ⚠️ **老数据的自愈扫描**(同一天):`store` 的 `refreshProgressStatus` 在**启动**与**每次
+ `dataChanged`** 时跑一遍,顺序固定为「扫描 → `refreshOverdue` → `refreshArchive`」—— 反了
+ 会把「结束日已过 + 进度已满」的先标成逾期、再补完成。两处刻意的取舍:
+ - **跳过已归档的**:归档 = 已离开工作面,不该被新一轮口径翻动(也就不会把管理页里的历史
+ 记录突然翻成「进行中」);
+ - **往「已完成」补时,活动日期取结束日**(不是 `nowStamp()`):归档判据
+ (`completedOn + N ≤ 今天`)与总览「本周完成」都读那条活动的日期。写成「升级那一天」会让
+ 7/30/90 天档的任务**再等 N 天**才走(与「结束时间已经到了」正相反),还会把一批历史任务
+ 算进本周。取结束日是相对最诚实的:这条任务「最晚那会儿就该做完」。
+ 首启扫到东西时,外壳给一条汇总 toast(照 `storageIssue` 的先例,见 §3.4)—— 不说的话,用户
+ 看到的是任务成批变色、成批从任务页消失,那看起来就是数据坏了。
+ - **新建的分区默认「立即」并且真的落盘**(2026-10-16 用户提的):档位默认值平时靠读取兜底
+ (`archiveDays[id] ?? ARCHIVE_NOW`),但**没落盘的值没有出处** —— 设置面板上写着「立即」,
+ 而 `archiveDays` 里根本没这个键。现在 `store` 订阅 `partitions.ts` 的
+ `onPartitionListChange`(新增分区与启动读档之后都会发),把**缺失**的档位补成「立即」并单次
+ `saveSetting`。它不能写在 `addPartition` 里:那个文件在依赖更下面一层,反过来 import `store`
+ 会成环。只补缺失这一条也同时保证了**老库冻结出来的「不归档」不会被碰**(那是已定义的值)。
+ 两道护栏都是踩过的坑:① 回调首行看 `archiveLoaded`,只有 `bootStore` **成功**读到档位才准
+ 写盘 —— `main.ts` 是 `await bootStore().catch(() => {})`,读档抛在 `loadSetting` 之前时
+ `archiveDays` 还停在 `{}`,那时按「缺失」补一遍会把四个分区全写成「立即」,正好冲掉老库冻结
+ 那层保护;② 回调不走 `setArchiveDays()`(它内部 `refreshArchive + dataChanged`),启动期会
+ 在读档与首帧之间多插一轮全量重画与写盘。
+ - ⚠️ **一处顺序脆弱点**(查上面那个 BUG 时顺手发现,**当前不可达**):`main.ts` 是
+ `bootStore()` 在前、`bootPartitions()` 在后,于是「老库冻结」那段遍历的 `PARTITIONS` 此刻还
+ 是内置那四个 —— 自建分区因此**不会**被冻成「不归档」。这个正确性完全依赖那两行的顺序;真要
+ 调过来,先看 `archiveLoaded` 那道护栏,并把 `partitions.ts` 的 `addPartition` 注释一起改。
- **归档之后的表现**:任务页不再列它(这正是「完成即归档」想要的效果),管理页是唯一能看到它的
地方(「已归档」那一档 + 行尾那枚「已归档」)。总览为此专门摆了第五张卡「**归档**」(见 §4.1):
四张状态卡仍是未归档口径、归档数单独一张 —— 收走的那批始终看得见,「已完成掉数」也总能对上
「归档涨数」。(活动分析的口径本来就含归档,源码里写明「归档只是列表里不再显示」。)
### 4.11 设置抽屉
**一页到底**,四组:外观 → 启动 → 分区 → 关于。不再分页签 —— 设置项不多,分页签只是多一次点击。
- - 关于面板按**实际功能**写(应用名 + 一句话定位 + 「功能」「特色」两份清单),不写技术栈、不提旧版本。
+ - 关于面板按**实际功能**写,**两组各三条**(2026-10-16 从十条精简:它就压在抽屉最下面,没人当正文读完,而这一块本来也不是功能说明书;砍掉的不是没做 —— 分区口令、Markdown 迁入都还在,只是不值得在这个宽度里逐条列),不写技术栈、不提旧版本。
+ - **「关于」组最终四行**(2026-10-17 定稿):
+
+ ```
+ 关于(v1.0.1) 展开 ▸ ← 标题里带版本,介绍块可折叠
+ 帮助文档 打开 ↗ ← 常驻动作,不参与任何折叠
+ 检查更新 [检查更新]
+ 下载与反馈 展开 ▸ ← 自己的折叠头,与「关于」互不联动
+ ```
+
+ - **版本号只留一处**:并进组标题「关于(v1.0.1)」,由 `getVersion()` 回填、拿不到时用常量兜底。单独的「版本」行撤了 —— 它和「检查更新」上下挨着,读起来像两个都能点的东西;「运行环境」行也撤了(日常无用,系统要求写进帮助文档即可)。
+ - **两个折叠头各管一块**:`#set-about-head` 只控制介绍块(`#set-about-intro`),`#set-about-downloads` 由 `#set-downloads-head` 单独控制;状态分别记在 `settings.aboutOpen` / `settings.downloadsOpen`,箭头按**自己那个头**的 `aria-expanded` 旋转。默认都收起。介绍块里是**下载渠道**(阿里云盘(国内推荐) 在前,GitHub Releases 备选,与 README「国内下载」同源)与**反馈与联系**(微信公众号 `Pyvan` 点击复制、GitHub 仓库点击开系统浏览器)。渠道顺序按**能不能打开**排,不按官方不官方。
+ - **「帮助文档」是常驻行**:`button.set-row.set-row-link#set-help`,整行可点、右侧「打开 ↗」,不随「关于」或下载行的开合变化。点击走 Rust 命令 `open_help`,用系统默认浏览器打开随程序嵌入的帮助文档(见下)。
+ - **云盘按钮下面有一行补救小字**(`#about-tip`,11.5px):`链接失效?换 GitHub Releases,或公众号 Pyvan 留言。` 两个行内动作分别走 `openExternal` 与 `copyText`。**不做链接探测**:实测阿里云盘对失效分享同样返回 200、响应体只有十几字节(脚本渲染 + 反爬),没有可靠判据;探测只会多一次联网、一次延迟和误报。也**不做警告色 / 背景块** —— 链接多数时候是好的,常驻一块警示色反而像下载坏了;文案只有一句,e2e 卡 40 字上限防止又长回去。
+ - **检查更新**是独立一行,**只有点它才联网**(不做启动时的静默检查:一个本地优先、无账号的工具,没有任何理由每次启动都偷偷发一个请求)。结果**只有两种**:强调色「发现新版本 vX」与中性色「没有查到新版本」;已是最新、网络失败、超时、GitHub 异常都归入后者 —— 用户不需要判断错误码。检查期间按钮禁用并显示「检查中…」,**最长 20 秒**必出结果。
+ - 发现新版本时:只把**下载与反馈**那一行展开(不写 `settings.downloadsOpen`,看完可收起)、打上「有新版本」标记,并在渠道块里补一行「发布日期 + 版本号 + 查看更新说明 ↗」,把国内渠道点亮(只变色、不动几何)。结果回到「没有查到」时清掉标记、日期与高亮。**不展开「关于」介绍块** —— 用户点的是检查更新,不是想读产品简介。
+ - **检测走 GitHub 网页重定向**(`src-tauri/src/update.rs`):`GET /releases/latest` 返回 302,只读 `Location` 里的 tag(`redirect::Policy::none()`,不下载 HTML),常规点击**不消耗 API 配额**。只有确实发现新版本时,才补一次 Releases API 取发布日期与说明,补不到也不影响结论(链接用 `releases/tag/vX` 拼出)。**不用官方自动更新**(`tauri-plugin-updater`):那要生成签名密钥、把公钥写进配置、每次发布再签名并上传一份 `latest.json`,而本仓库发布是本地手动的(没有 CI),为省用户点一次「下载」不值当。**阿里云盘不能当检测源**:它没有官方 API、分享页是 JS 渲染的;v0.x 是靠调**作者本机**的 `aliyunpan` CLI 去 `ls` 解析文件名 —— 普通用户机器上没有那个 CLI,所以它每次都悄悄回落 GitHub,别再把这个做法搬过来。
+ - **版本比较只留一处**(Rust 侧算完直接给结论,前端不重算)。判据容忍 `v` 前缀与 3 / 4 段 —— v0.x 栽过:按 3 段截断把 `0.1.2.4` 读成 `0.1.2`,于是「明明发了新版却说自己是最新的」;缺的段按 0 补,所以 `1.0.0` 与 `1.0.0.0` 相等。
+ - 外链一律走 `shell/links.ts`(桌面端 `plugin-opener`,纯网页预览回落 `window.open`):地址只在那一处,换渠道不必满仓库找旧链接。
- 「窗口置顶」不在设置里重复一份:标题栏的图钉是唯一入口。
- 启动组:开机自启、全局热键。
+ **帮助文档**(`desktop/src-tauri/resources/help/` + `src-tauri/src/help.rs`):
+
+ - 单一 HTML 源文件 + 单个 `<style>`:无脚本、无外链字体 / 样式、无行内 `style`,图片是本地 1x JPEG。**不与 Markdown 混排** —— 两套标题 / 列表 / 代码样式会互相覆盖。
+ - 内容固定五块:目录、各模块功能定位与操作、FAQ、版本更新简述、数据与备份;目录锚点、页面名、快捷键、当前版本由 `resources/help-check.mjs`(`npm run check:help`)核对,负向清单挡住已撤掉的能力。
+ - **不用 `bundle.resources`**:便携版交付的是裸 `Tadado2.exe`,附属文件不在安装目录就 404。改用 `include_str!` / `include_bytes!` 编进二进制,`open_help` 首次打开时写到 `app_cache_dir()/help/<version>/`(`.ready` 标记避免重复写盘)、把 `{{VERSION}}` 换成当前版本,再交给 `tauri_plugin_opener::open_path`。缓存按版本隔离,升级自动换新。
+ - 截图只能来自**演示空间的样例数据**:`resources/screenshots.mjs` 用独立浏览器上下文(localStorage 为空 → mock),并在拍之前断言「优化列表页性能」确实在,防止误连真实库。
+
---
## 5. 外壳
### 5.1 标题栏
- 无边框 + 自绘,`data-tauri-drag-region="deep"` 让子元素区域也能拖。
- 左侧**只有软件名**:图标与版本号徽章都撤了(版本号在设置 → 关于里,标题栏放它是装饰)。
- 右侧:置顶图钉、主题切换、最小化 / 最大化 / 关闭。
- 关闭 = 收起常驻,真正退出走托盘菜单(`app_exit`)。
### 5.2 托盘、热键、自启
- 托盘由 **Rust 侧**在进程启动时创建(`lib.rs`),前端只通过 `trayBridge.ts` 接收事件后切页 / 开设置。
- 托盘菜单:显示窗口 / 设置 / 退出 Tadado2。**「新建任务」撤了**(2026-09-17)—— 点它只能把主窗口
叫出来再弹浮层,不值当。
- 全局热键 `Ctrl+Shift+Space` 唤起 / 收起主窗口。它挂了窗口收起后就唤不回,所以 `setupHotkey`
失败必须告警。注册**必须幂等**(`isRegistered` → 先摘再注册):Rust 侧那份注册不随 webview
重载消失,直接再 `register` 会以「已注册」失败,于是每次重载都弹一句**假的**「热键不可用」。
- **跨日续跑**(`rollover.ts`,2026-09-20):`TODAY` 是模块加载时算一次的(日期坐标见 §3),
而托盘常驻意味着「**开着过夜属于正常用法**」—— 不处理的话,过了午夜整个应用还停在昨天:
逾期不重算、新建任务的默认截止是昨天、新活动的时刻也记在昨天;而问候语与轴上的「现在」
读的是真实时钟,于是页面自相矛盾(问候语说「早上好」,日期还停在昨天)。
做法:每分钟看一眼本地日历日,变了就 `location.reload()` —— 走的是**设计里已经有**的那条路
(托盘与热键由 Rust 侧持有、与 webview 生命周期解耦,重载本就在预期之内,见 §1.3 表格上方
那句注释)。两条约束:① 只在 Tauri 里跑(预览与 e2e 没有常驻这回事,而且跑到一半被重载更
难查);② 有**未提交的输入**(模态框、抽屉里那段还没点「按 md 更新任务」的 md)时跳过,
等下一分钟 —— 批量草稿走 kv 丢不了,那段 md 没有别的地方存。
为什么不把 `TODAY` 改成取值函数:那要动数据层与二十来个调用点,且每个派生值都得各自订阅
一次「跨日」,漏一个就回到同样的问题。
- 单实例:第二次启动由已运行实例接管并拉回窗口。
- 开机自启用 `tauri-plugin-autostart`;浏览器里降级为「不可用」并明确标出。
### 5.3 浮层与提示
- **右侧抽屉互斥**(`panels.ts`):设置与任务抽屉同时最多一个。
+ - **点面板外收起**(`panels.ts`,2026-10-16 加):两块抽屉都没有遮罩,鼠标按在面板**之外**
+ (页面内容、侧栏、标题栏……)就收起它,不必每次都去够右上角那个 X。判定收在 shell 层,
+ 是因为两块抽屉的规则一模一样,各写一份迟早分叉;为此 `registerPanel` 多收一个**根节点
+ getter**(任务抽屉的 DOM 是懒建的,注册时还不存在)。面板层仍然只认「id / 根节点 /
+ 怎么关掉我」,不认识任务域。
+ 豁免分两类。**按层**的:`.mask`(模态铺满全屏且盖在抽屉之上,不豁免会「点一下关掉
+ 两样东西」)、`.lock-screen`、`.ctx-menu`(右键菜单,否则点「打开维护抽屉」会先把当前
+ 抽屉关掉、闪一下再开)。**按手势**的:`[data-panel-keep]` 标的是「打开抽屉的那只手」
+ (任务列表的行、侧栏的设置入口),点它们不算「点在外部」,否则「同一条任务再双击一次
+ 收起」会当场失效:双击的第一下先关、第二下 `isTaskOpen` 已是 false 于是又开,净效果是
+ 闪一下、抽屉还开着。这个属性的值必须是它服务的那块面板的 **PanelId**(`"task"` /
+ `"settings"`),只有「当前开的正是它」时才豁免 —— 写成裸属性等于空串、不豁免:任务行
+ 对设置抽屉来说不是打开手势,设置开着时点一行,那就是真的点了外面,该收起来。
+ 只认鼠标左键(右键是「要处置这一行」,不该顺手关抽屉);用 `pointerdown` 而不是 `click`,
+ 与 ctx 菜单 / 下拉菜单的既有选择一致,也不会和任务页甘特拖动后的 `swallowAfterDrag`
+ (在 capture 阶段拦 click / dblclick)相互干扰。
- 破坏性操作二次确认(`confirm.ts`):默认焦点给「取消」,Esc 与点遮罩都算取消。
- 提示条(`toast.ts`)显示 2200ms;用于「已导出」「口令错误」这类结果回执。
### 5.4 主题与配色
- 主题 `light` / `dark` / `sys`,落在 `data-theme`;首帧前就要定好,否则闪白。
- 热力图配色方案落在 `data-scheme`(`tadado-heat-scheme-v2`),即改即生效。
- 配色方案的取值只能来自 `tokens.css` 里的色阶变量。
---
## 6. 构建与验证
### 6.1 脚本与 CI
```bash
cd desktop
npm install
npm run dev # 纯前端预览(非 Tauri 环境自动降级,只能调样式)
npm run build # tsc + vite build,产物在 dist/
npm run e2e # 构建 + 真浏览器冒烟(唯一的验证闸门,约 2–3 分钟)
npm run tauri dev # 真实窗口(首次需编译 Rust,约 1–2 分钟)
npm run tauri build # 出安装包:NSIS + MSI → src-tauri/target/release/bundle/
```
`npm run e2e` 的第一件事是 `tsc && vite build` —— 构建不过直接停在那里,所以它同时是
类型检查的入口。**跑法**(2026-09-21 定的):一轮改动全写完再跑一次,不要每改一处就跑;
动手前先 `npx tsc --noEmit` 把编译错误挡在前面;用户没让跑就不跑(理由见 CLAUDE.md)。
另外两个脚本在**仓库根**、与 README 同级 —— 它们服务的是整个仓库,不属于应用本身:
```bash
node resources/skill/tadado-activity-import/scripts/migrate-activity.mjs <清单> # 旧数据迁移,见 §7.1
node resources/screenshots.mjs # 重拍 README 的界面截图(先 cd desktop && npm run build)
```
`npm run dev` 下 `@tauri-apps/api` 的窗口调用走本地状态降级,页面与样式都能看,
只有窗口行为(置顶、托盘、热键)不可用。
CI(`.github/workflows/desktop.yml`)在 `desktop/**` 有改动时跑 `npm ci` → `npm run build`
→ `npx playwright install --with-deps chromium` → `npm run e2e`。
### 6.2 冒烟测试覆盖什么
`e2e/smoke.mjs` 真开一个 Chromium(1280×860)把主流程点一遍,分这些组:
> 切页只显示一页 · 四张表都能翻页且档位一致 · 交付口径(启动状态演示空间 100 条)·
> 总览的数字必须点得开且对得上 · 任务页(甘特窗口固定 32 天 / 拖动平移 · 搜索 ·
> 排序 · 状态与优先级筛选)· 维护抽屉(md 预览与写回 · 时间线正序 · 进度的草稿式提交与再编辑 ·
- > 记录的编辑与删除)· 完成后归档(默认「立即」=勾完即离开;「7 天」=在任务页存活)·
- > 标签 · 图谱 · 写法里的活动行 · 导出(md/txt/xlsx)· 数据迁入(含同名提示)·
+ > 记录的编辑与删除)· 起止倒挂校验(两个方向越界都拒收并回退显示 · 同日放行 ·
+ > md 里的倒挂截止不写回)· 完成后归档(默认「立即」=勾完即离开;「7 天」=在任务页存活)·
+ > 标签 · 图谱 · 写法里的活动行 · 导出(md/txt/xlsx)· 数据迁入(含同名提示与「起点按截止日
+ > 修正」的逐条列出 · 导入后库里没有倒挂区间)·
> 分区口令与锁屏 · 一屏到底 · 存储版本与读入口归位 · 老存档归位 · 无 console 报错
几条**判据的写法**本身是设计的一部分:
- 「一屏到底」判的不是「没有滚动条」,而是**不用滚就能看到列表与翻页**:页面自身不滚动
(`scrollHeight - clientHeight ≈ 0`)、列表区真的吃到了剩余高度、工具行 / 分页器的下沿还在视口里。
三条缺一条,用户就还得滚。
- 「总览的数字 = 点开后的行数」比的是**筛出来的总数**而不是当前页行数 —— 100 条之后一页装不下,
比行数会误判。名单**从界面上取**(遍历 `.tile`),不写死清单:写死的那次列了三张卡,于是
第四张「今日到期」的 `onClick` 漏了都没人发现 —— 一条只在某些钟点红的断言会被当成时钟问题,
一条漏了对象的断言则**永远不会红**。
- 「导出的 xlsx」逐项校验 CRC;「导出的 md / txt」校验三层数量一一对应。
- 跟时间沾边的断言有**三条规矩**(这一类踩过三次,每次红起来都像是被测代码坏了,见
`smoke.mjs` 顶部):要「今天」就**现算**;跟本地日历日对齐**别用 `toISOString()`**
(那是 UTC,东八区每天 00:00–08:00 会算成昨天 —— 有一条断言就这么每天早红八小时);
挨着日期边界的断言**容许「上一步的值」**(上一分钟 / 上一小时 / 前一天)。文件顶部另有
- `localIso()` / `todayDay()` / `nearestDay()` 三个助手,日期比较一律走它们(数据里不存年份,
- 直接比 `"MM-DD"` 会在 1 月倒挂)。判据是「**将来还会不会自己红**」,不是「今天跑不跑得过」。
+ `localIso()` / `todayDay()` / `nearestDay()` 三个助手(2026-10-16 又加了 `isoOfDay()` /
+ `dayOfIso()`:日期框收 ISO 串、判据比的是天数,两边口径不一致时测试会以为自己填的是另一个
+ 日子),日期比较一律走它们(数据里不存年份,直接比 `"MM-DD"` 会在 1 月倒挂)。判据是
+ 「**将来还会不会自己红**」,不是「今天跑不跑得过」。
- 最后一条永远是「**无 console 报错**」。
2026-09-21 那一轮(凌晨跑,red 了 12 条)又添了四条规矩,都是**断言自己的前提**出了问题:
- **口径要跟被测代码一致,不是「跟常识一致」**:判「今天」得按应用的约定算
(`TODAY = Date.UTC(本地年月日) / DAY_MS`,见 `data/time.ts`)。写成 `Date.now()` 折天、
或 `toDateString()`,量到的是**真实 UTC 时刻**那一天 —— 本地 00:00–08:00 正好差一天,
「昨天的活动」会被判成今天。
- **别把「当前时刻晚于种子里的时刻」当前提**:演示数据的「今天」活动带**固定钟点**
(最早 06:20),凌晨跑时那些时刻还没到,于是「新建的任务排在第一条」这类断言不成立。
要量的是「它出现在第一页里」—— 那才是「事件驱动重画」想证明的事。
- **前提要自己造**:分页器在只有一页时不摆翻页件 —— 断言若直接去点 `▶`,等的是一个不存在的
元素,表现是**超时 30 秒**而不是一条看得懂的失败。这一条当年要「先切『全部』把页数造出来」
(默认档位「本周」常常不足一页);档位撤了之后,任务页默认就是「全部未归档任务」、页数天然
够,但**规矩没变**:点翻页件之前先确认这一屏真的多于一页。
- **别只数个数,要量不变量**:这条教训来自已经拆掉的档位分段(`.seg-in`)—— 那时「有几个高亮段」
取决于那批任务的条形有没有跨过那一格(逾期任务的条整段在今天之前,合法地没有高亮段)→
随数据时红时绿,改成结构性不变量才立得住。**那些高亮后来全撤了**(见 §4.2),这条现在的
对应物是「条上分段必须**恒为 0**」(`segSplit === 0`)—— 它钉的是「别再退回一条上叠两个量」
这条路本身,而不是某个会随数据变的数字。
一句话:红了先分类 —— 是被测行为真的变了、断言的前提没设对、还是数据本身随时间变。
后两类改断言,第一类才改代码。
### 6.3 为什么必须有 e2e
`tsc` 看不出运行时故障。曾经 `dropdown.setValue` 的回调 `onPick` 造成无限递归
(`paint → setValue → onPick → paint`),抽屉节点建出来了却永远加不上 `.open`,
表现是「双击、右键都没反应」—— 类型完全合法、构建照过,只有真点一遍才暴露。
自动化做不到的部分:**样式与视觉的对照**。改完样式记得对着
[`resources/ui-mockup/tadado-2.0.html`](resources/ui-mockup/tadado-2.0.html) 核一遍差异。
### 6.4 README 的截图(`resources/screenshots.mjs`)
README 里的界面图是**真拍**的,不是示意图 —— 对着一份真实构建跑 Playwright,产到
`resources/screenshots/`。三条规矩:
1. **视口 1180×760**,正是应用窗口的默认尺寸(§2.4)。截出来的就是用户打开软件看到的那一屏。
2. **画面里的数据就是演示空间那 100 条样例**,不另造数据:README 展示的必须是用户装上就能看到的东西。
3. **抽屉那张挑时间线最长的任务**(用搜索框定位到「优化列表页性能」,8 条记录)。列表默认按
截止排序,前十几行全是压测生成的任务、每行只有一条「创建任务」—— 而那张图要证明的正是
「过程留在时间线上」,拍空历史等于没拍。
同一份脚本还拼出**仓库的社交预览图**(1280×640,`social-preview.png`):品牌 + tagline +
一张缩小的真实界面,在 GitHub 的 Settings → Social preview 里上传。
- 改完界面记得重跑一次 —— 图和界面不符比没有图更糟。
+ **帮助文档的截图**是同一趟跑出来的另一套产物(2026-10-17):独立浏览器上下文、1x JPEG(质量 80,
+ 宽度 1180),写到 `desktop/src-tauri/resources/help/img/`。它必须只含演示空间的样例数据 ——
+ 所以截图前先搜「优化列表页性能」并断言命中,命中不了就报错退出,绝不把本地数据库里的真实任务
+ 拍进随程序分发的文档。八张:overview / tasks / drawer / graph / activity / manage / task-form /
+ settings-about(最后一张核对「关于」标题带版本、无运行环境行、帮助行与下载行都在)。
+ 改完界面记得重跑一次 —— 图和界面不符比没有图更糟;帮助文档另有 `npm run check:help` 核对文案
+ 与功能是否还对得上。
+
---
## 7. 迁移与交付
### 7.1 从 v0.x 迁移数据
路线:v0.x「活动分析」导出清单 → `resources/skill/tadado-activity-import/scripts/migrate-activity.mjs` 转成任务行 →
v1.x **任务管理页 →「数据迁入」** 导入。**人手写的活动时间线一起过来**。工具的用法、对账口径与
导入后的复核见 [`resources/skill/tadado-activity-import/SKILL.md`](resources/skill/tadado-activity-import/SKILL.md)。
工具的设计原则是「**先对账,再动手**」:默认只解析只报告、不写文件;认不出的行必须停下问;
源里每一行活动都要有去向(输出 / 并入续行 / 重复剔除),数字不平不许写出。
> 转换器的输出必须能被**下游真正的解析器**验一遍。这条来之不易:截止挖掘用了两条正则,
> 后一条把前一条的结果覆盖回箭头**左边**的旧日期(`截止时间 A -> B` 读成了 A)——
> 靠「把转换结果再喂回应用数一遍」才看出来。**「数量对」不能只是转换器自己说的。**
带不过来的一律要在导入前说明:**「逾期」**(由截止日期自动算)、优先级;还有旧版**自动写下**
的系统记录(创建 / 延后 / 状态变更 / 进度变更)—— 转换工具会把它们滤掉,
因为那几项的**结果已经写在任务行上**(方括号 = 状态、`::` = 进度、`⏰` = 截止),而活动行导入后
一律是「人手写的一条进展」,带进来等于给系统记录挂个人名。想全留着用 `--keep-system`(默认不开)。
⚠️ **顺序**:截止与状态正是从这些行里**挖出来**的 —— 工具是**先挖字段、再滤行**;先滤就把字段丢了。
**创建日与起止不用旧版记账,但也不是导入日**:应用在导入时把 `created` 与 `start` 都落成
**这条任务最早一条活动的日期**(没有活动行才落导入日,见 `data/markdown.ts` 解析收尾那段)。
理由:迁进来的任务历史全在活动行里,一律落导入日会把整批任务的「创建于」挤在同一天 ——
**创建日是这次迁移里唯一会被整个抹平的时间维度**(截止 / 进度 / 状态都能从活动文本里挖到)。
代价是它给的是「第一次动手」那天,不是真正的创建时刻(旧版的「创建任务」记录按上面的规矩
不转移);`start` 一起改,否则会出现「创建于 01-05、甘特色条却从 09-20 才开始」这种矛盾。
### 7.2 交付口径
- **演示空间 100 条**(28 原型手写 + 72 生成),其余分区为空 —— e2e 有断言锁住。
100 里有 1 条是**已归档**样例(管理页的归档列要有东西看);**其余已完成的那些会在启动时
被「完成后归档=立即」(§4.10)一并收走** —— 所以任务页列出的**明显少于 99 条**。e2e 因此钉的是
恒等式「未归档 + 归档 = 100」与「归档明显不止样例那 1 条」,**不写死两个数**:生成的那 72 条里
已完成多少由生成器决定,写死就会一改生成逻辑就红。
- **三个产物**(`npm run tauri build`):`bundle.targets = "all"` 在 Windows 上给的是 **NSIS +
MSI** 两个 —— `nsis/Tadado2_<版本>_x64-setup.exe`(约 2.6 MB)、
`msi/Tadado2_<版本>_x64_en-US.msi`(约 3.6 MB)。**便携版不在 bundler 的目标里**,两步手工:
① 把 `target/release/desktop.exe` 复制到 `bundle/portable/Tadado2.exe`(主程序二进制叫
`desktop`、产品名才叫 Tadado2);② 压成 `bundle/portable/Tadado2-portable.zip`(约 3.3 MB)。
**交付出去的是那个 zip,不是裸 exe**:7.7 MB 的 exe 直接发出去,用户拿到的是「一个不知道
要不要双击的东西」;zip 3.3 MB,一眼就知道是「下载 → 解压 → 运行」。
**每次打包都要记得这两步** —— 漏了第一步,README 承诺的便携版会是一个旧二进制;漏了第二步,
zip 里装的是上一次的 exe。两种都**看不出来**(名字一样)。
- **第四份交付物是 skill 包**(`tadado-activity-import.zip`,约 15.6 KB)—— 它**不是应用的产物**,
而是给用户的那份「旧数据迁移」skill:用户下载 Tadado2 之后,要让自己的 Claude Code / Codex
帮忙搬旧数据,就得有它。压的时候**压目录本身**(`-Path resources\skill\tadado-activity-import`),
让 zip 里保留 `tadado-activity-import/` 这一层 —— 少了它,解压到 `.claude/skills/` 是加载不到的。
它不跟 `npm run tauri build` 走,也不在 `bundle/` 里自己出现,**每次发版都要记得手工压一次**。
- **用户数据不在安装目录里**,所以重装 / 换路径 / 升级都不影响它。这是设计使然,不是碰巧:
Tauri 按 `identifier` 定位 `app_data_dir`,值就是 `%APPDATA%\com.tadado.app\tadado.data`
(`db.ts` 里是 `Database.load("sqlite:tadado.data")`)。**这也是 `identifier 不能改` 的
真正原因** —— 改它等于换一个数据目录,在用户眼里就是「数据没了」。
两条安装路径的实际行为,都是从**生成的脚本**里核的(`target/release/nsis/x64/installer.nsi`、
`target/release/wix/x64/main.wxs`),不是照惯例推的:
- · **NSIS** 默认 `currentUser` → `%LOCALAPPDATA%\Tadado2`(免管理员)。卸载时**只有
- 同时满足两件事**才删数据:勾上「删除应用数据」**且**不是升级(`UpdateMode <> 1`)——
- 也就是升级永远不碰它。
+ · **NSIS** 默认 `currentUser` → `%LOCALAPPDATA%\Tadado2`(免管理员)。**从 1.0.1 起改用定制模板**
+ (`src-tauri/windows/installer.nsi`,取自 CLI `tauri-cli-v2.11.4` 的官方模板,仅两处改动):
+ ① 升级(检测到旧版本)**仍显示维护页**,推荐项「原地升级(保留任务与分区数据)」放在第一项并
+ 默认勾选;选它不执行旧卸载器(删数据的代码只在卸载段),选第二项才「先卸载再安装」。
+ 同版本重装、降级保持原询问页,MSI/WiX 迁移仍强制先卸载。② 卸载页「删除应用数据」复选框**保留且默认不勾**,
+ **勾选后二次确认**(说明会永久删除任务/分区/设置且不可恢复,默认按钮「否」,选「否」留在本页)。
+ 卸载时**只有同时满足两件事**才删数据:勾上「删除应用数据」**且**不是升级(`UpdateMode <> 1`)。
+ 模板与 CLI 版本绑定:升 `@tauri-apps/cli` 必须按新 tag 重新同步模板并重放两处补丁(失配会在
+ 构建期报错,不会静默产出坏安装包)。
· **MSI** → `%ProgramFiles%\Tadado2`(需管理员),`MajorUpgrade AllowDowngrades="yes"`:
自动顶掉旧版本、也允许装更旧的版本。它的卸载只删自己装的目录与快捷方式,全文**不出现**
`%APPDATA%\com.tadado.app`。另外它会回头找 NSIS 写下的注册表键
(`HKCU\Software\tadado\Tadado2`)—— 先装过 NSIS 版再用 MSI 装,会落在同一个目录。
- **降级读新库安全,但会丢新字段**:`runMigrations` 只跑 `from === current` 的步骤 —— 版本号
比链子新就一步不跑(不报错、不改库),数据照读;但旧程序下一次保存会把它**不认识的新字段
写没**(保存是全量覆盖)。所以「装回旧版本看看」可以,别在那之后继续录数据。
- `<productName>` = `Tadado2`,版本从 **v1.0.0** 起(与 Py 版区分)。
- 版本号四处一起走:`tauri.conf.json` / `Cargo.toml` / `package.json` / 设置里的兜底值。
Tauri 回话给的是纯数字,显示时补 `v` 前缀。
---
## 8. 附录
### 8.1 关键常量(`src/pages/shared.ts`)
| 名称 | 值 | 说明 |
|------|-----|------|
| `PAGE_SIZES` | `[20, 30, 50, 100]` | 四张表共用的每页档位 |
| `PAGE_SIZE` / `TASK_PAGE_SIZE` / `REPORT_PAGE_SIZE` | `20` | 各自的默认档位 |
| `FEED_LIMIT` | `50` | 总览近期活动的**上限**:这张卡只摆最近的 50 个任务(2026-09-20 用户定的:「只想显示近期的 50 条,不想显示太多」)。上限是这张卡的**定位**(一眼看最近发生了什么),不承担「翻遍历史」——那件事归活动分析页 |
| `FEED_PAGE_SIZE` | `= FEED_LIMIT` | 总览近期活动的默认档位。默认就等于上限,所以平时**只有一页**(一页时不摆翻页箭头)。后续可能**搬到设置里**做成可配项 —— 真搬时改这一处常量 |
| `GANTT_LIMIT` | `20` | 总览焦点时间轴上限(无分页) |
| `URGENCY_LABEL` | `["紧急","重要","关注","普通"]` | 优先级**术语**(P0–P3)。只有名字 —— 展示要带编号用下面的 `urgencyText()` |
| `urgencyText(level)` | `紧急(P0)` | 名字 + 编号。总览的优先级分布、任务页的优先级下拉、任务行悬停小卡 |
| `URGENCY_LEVELS` | `[0, 1, 2, 3]` | 带类型的档位迭代器 —— 遍历时不用写 `as Urgency`(断言的写法,档位数变了不会报错) |
| `STATUS_LABEL` | `{overdue:"逾期", todo:"待办", doing:"进行中", done:"已完成"}` | 状态文案。**下标是 `StatusName`(四档)而不是 `TaskStatus`(三档)** —— 「待办」作为当前状态已删,但活动记录里的 `from: "todo"` 还要照原样显示 |
| `matchesStatus(task, filter)` | — | 状态筛选判定。总览卡 / 任务页 chip / 管理页筛选 / 图谱页过滤,四个消费方共用(「待办」删掉后它就是一对一,不再有合并口径) |
| `AXIS_H`(`overview.ts`) | `150` | 时间轴基准高度,**必须与 CSS 的 `.tdt` 一致** |
| `GANTT_DAYS`(`tasks.ts`) | `32` | 任务页甘特窗口固定天数(默认 `ganttAnchor = TODAY - 16`)。后续可能进设置 |
| `TODAY`(`data/time.ts`) | 真实时钟 | 界面里的「今天」 |
| `DEMO_TODAY`(`data/mock.ts`) | `[9, 12]` | 仅样例数据排布锚点 |
### 8.2 依赖清单
| 包 | 用途 |
|----|------|
| `@tauri-apps/api` | 前端调用宿主能力 |
| `@tauri-apps/plugin-sql` | SQLite |
| `plugin-fs` / `plugin-dialog` / `plugin-opener` | 文件读写、系统弹窗、打开外部链接 |
| `plugin-global-shortcut` / `plugin-autostart` | 全局热键、开机自启 |
Rust 侧另有 `plugin-single-instance`、`plugin-persisted-scope`(**必须排在拥有 scope 的插件之后**)、
Tauri features `tray-icon` / `image-png` / `image-ico`。
**没有任何前端运行时依赖**(无框架、无 UI 库、无 xlsx 库)—— 这条要守住。
### 8.3 已撤的设计(别再照着旧文档实现)
| 撤掉的 | 时间 | 为什么 |
|--------|------|--------|
| 文件级「导出 .md / 导入 .md」 | 2026-09-17 | 导出改成了给人看的清单;导入被批量新建取代(当场看得见解析出几条) |
| 任务管理「导入 .md」 | 2026-09-17 | 同上 |
| 工具行的「快速新建」 | — | 新建统一走页头「+ 新建任务」,打开的是和编辑同一套表单 |
| 工具行那个只写「批量」的按钮 | 2026-09-18 | 先挪到页头、改名「批量新建任务」,再整体搬到任务管理页、改名「数据迁入」。它更常被用来导一批旧数据,和那页的「导出 ▾」正好一进一出;页头的位置该留给每天都用的「+ 新建任务」 |
| 抽屉底部的「删除 / 保存」 | — | 改动即时生效,没有「改了没保存」的状态 |
| 托盘菜单的「新建任务」 | 2026-09-17 | 只能把窗口叫出来再弹浮层,不值当 |
| 设置里的时间轴档位 | 2026-09-17 | 工具行是唯一入口(工具行那排本身也在 2026-09-21 撤了) |
| 设置里的「窗口置顶」 | 2026-09-17 | 标题栏图钉是唯一入口 |
| 标题栏的图标与版本号徽章 | — | 装饰;版本号在设置 → 关于 |
| 设置的分页签 | — | 一页到底 |
| 设置 → AI 助手页签 | — | 7 行没有一行接了后端,其中「专用工作区」还指向已删除的目录 |
| `registry.ts` 的 `blocks` / `group` | — | 没有消费方 |
| 循环字段 `+1w` | — | 从来没有行为、界面上也没有编辑入口,只是随 md 文本空转(解析时仍吃掉旧标记) |