feat: 个人轻量看板工具 MVP
从零实现一个 local-first 的个人任务看板,数据只存在浏览器本地,不依赖后端。 功能 - 固定三列看板(待办 / 进行中 / 已完成),支持拖拽改状态与列内排序 - 快速新增、详情编辑、删除撤销、已完成归档 - 搜索与筛选(关键词 / 优先级 / 标签 / 今日到期 / 逾期) - 今日视图:按紧急度分组(逾期 > 今天到期 > 进行中)+ 统计卡片 - 导出导入 JSON;启动时自动留快照,保留最近 3 份 - 键盘快捷键(N / 「/」/ ? / Esc) - PWA:可安装、离线可用、更新提示 技术要点 - 持久化采用 TaskRepository 端口 + 整份快照读写,schemaVersion 迁移表 显式登记升级规则(当前 v2,v1 -> v2 为归档功能补 archivedAt) - 同列排序用相邻中点插入,间距不足时按列 normalize,避免浮点精度耗尽 - 拖拽只消费 Sortable 的事件,store 保持唯一数据源,不接管数据 - 数据损坏时先备份到 *.corrupt.<时间戳> 再报错,不覆盖用户数据 - 引入 vue-router 拆分看板与今日两个视图(见 docs/adr.md ADR-005) 验证 - 单元测试 437 个(33 个文件),行覆盖率 96.8% - 端到端测试 23 个(Playwright),含真实拖拽与断网离线场景 - npm run build 通过,产物可静态托管 注:部署时需配置 SPA 回落,否则直接访问 /today 会 404(详见 README)。
This commit is contained in:
+30
@@ -0,0 +1,30 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Playwright
|
||||||
|
test-results
|
||||||
|
playwright-report
|
||||||
|
blob-report
|
||||||
|
.playwright
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
dist
|
||||||
|
coverage
|
||||||
|
node_modules
|
||||||
|
package-lock.json
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"semi": false,
|
||||||
|
"singleQuote": true,
|
||||||
|
"printWidth": 100,
|
||||||
|
"trailingComma": "all"
|
||||||
|
}
|
||||||
Vendored
+3
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"recommendations": ["Vue.volar"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
# 看板 · 个人任务管理
|
||||||
|
|
||||||
|
一个轻量的个人看板工具:打开即用、数据存在本地、不依赖后端。
|
||||||
|
|
||||||
|
- 固定三列:**待办 / 进行中 / 已完成**
|
||||||
|
- 快速新增、拖拽排序、详情编辑、删除撤销
|
||||||
|
- 搜索与筛选(关键词 / 优先级 / 标签 / 今日到期 / 逾期)
|
||||||
|
- **今日视图**:按紧急度汇总今天要做的事,含本周完成等统计
|
||||||
|
- 键盘快捷键
|
||||||
|
- 导出导入 JSON + 启动自动备份
|
||||||
|
- **可安装为 PWA,支持离线使用**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 快速开始
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
打开终端提示的地址即可。首次进入是空看板,在任意列顶部的输入框里输入标题、回车即可创建任务。
|
||||||
|
|
||||||
|
## 脚本
|
||||||
|
|
||||||
|
| 命令 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| `npm run dev` | 启动开发服务器 |
|
||||||
|
| `npm run build` | 类型检查 + 生产构建,产物在 `dist/` |
|
||||||
|
| `npm run preview` | 本地预览生产构建 |
|
||||||
|
| `npm run typecheck` | 只做类型检查 |
|
||||||
|
| `npm run test` | 单元测试(Vitest) |
|
||||||
|
| `npm run test:coverage` | 单元测试 + 覆盖率报告 |
|
||||||
|
| `npm run test:e2e` | 端到端测试(Playwright,默认驱动系统已安装的 Chrome) |
|
||||||
|
| `npm run lint` / `npm run format` | 代码检查与格式化 |
|
||||||
|
|
||||||
|
## 快捷键
|
||||||
|
|
||||||
|
| 按键 | 作用 |
|
||||||
|
|---|---|
|
||||||
|
| `N` | 聚焦「待办」列的快速新增输入框 |
|
||||||
|
| `/` | 聚焦搜索框 |
|
||||||
|
| `?` | 打开快捷键帮助 |
|
||||||
|
| `Esc` | 关闭当前面板 |
|
||||||
|
|
||||||
|
在输入框内输入时不会触发快捷键。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 两个视图
|
||||||
|
|
||||||
|
顶部导航可在两个视图之间切换:
|
||||||
|
|
||||||
|
| 视图 | 路径 | 组织方式 |
|
||||||
|
|---|---|---|
|
||||||
|
| 看板 | `/` | 按**状态**:待办 / 进行中 / 已完成 |
|
||||||
|
| 今日 | `/today` | 按**紧急度**:已逾期 → 今天到期 → 进行中 |
|
||||||
|
|
||||||
|
今日视图顶部有四张统计卡片(今日待办 / 逾期 / 今天到期 / 本周完成),下方是可直接勾选完成的待办列表。
|
||||||
|
|
||||||
|
分组是**互斥**的:一张卡片只会出现在一组里,归属优先级为「逾期 > 今天到期 > 进行中」,所以你能一眼数清今天到底有几件事。已完成和已归档的任务不会出现在今日视图。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 安装与离线(PWA)
|
||||||
|
|
||||||
|
应用可以像原生应用一样安装到桌面或手机主屏,安装后支持**完全离线**使用 —— Service Worker 会预缓存页面与静态资源,断网后照常打开,数据本来就在本地。
|
||||||
|
|
||||||
|
- 桌面 Chrome / Edge:地址栏右侧会出现安装图标
|
||||||
|
- 手机:浏览器菜单 →「添加到主屏幕」
|
||||||
|
- 安装后长按图标有「今日」快捷方式,直达 `/today`
|
||||||
|
|
||||||
|
更新策略是 **prompt**:检测到新版本时会在底部弹出提示,由你决定何时刷新,不会打断正在进行的编辑。
|
||||||
|
|
||||||
|
图标由 `scripts/generate-icons.py`(Pillow)生成,需要改图标时运行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python scripts/generate-icons.py
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 数据与备份(重要)
|
||||||
|
|
||||||
|
**数据只保存在你这台设备的浏览器里**(localStorage,键名 `kanban.tasks`)。没有服务端,也不会自动同步到其它设备。
|
||||||
|
|
||||||
|
这意味着以下操作会导致数据丢失:
|
||||||
|
|
||||||
|
- 清理浏览器数据 / 缓存
|
||||||
|
- 使用隐私模式
|
||||||
|
- 换浏览器或换设备
|
||||||
|
- Safari 的 7 天未访问存储清理策略
|
||||||
|
|
||||||
|
因此工具内置了两道保护:
|
||||||
|
|
||||||
|
1. **启动自动备份**:每次打开看板时留一份快照,最多保留最近 3 份,可在「备份」面板中一键恢复。
|
||||||
|
2. **手动导出**:「备份」面板 → 导出为 JSON,文件名形如 `kanban-2026-10-18.json`。
|
||||||
|
|
||||||
|
导入是**覆盖式**的:选中文件后会先校验,再显示将被覆盖的任务数量,需要二次确认才会执行。文件不合法时不会改动任何现有数据。
|
||||||
|
|
||||||
|
> 如果需要在多设备间同步,需要自行接入后端;`TaskRepository` 接口就是为此预留的扩展点(见下)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 部署
|
||||||
|
|
||||||
|
产物是纯静态文件,把 `dist/` 目录交给任意静态托管即可(Nginx、Vercel、Netlify、GitHub Pages 等)。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
# 将 dist/ 上传或指向静态服务器
|
||||||
|
```
|
||||||
|
|
||||||
|
Nginx 示例:
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
root /var/www/kanban/dist;
|
||||||
|
index index.html;
|
||||||
|
location / {
|
||||||
|
# 必须配置:/today 是真实路径,刷新时文件不存在,需要回落到 index.html
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Netlify / Vercel 等平台,在 `dist/` 同级放一个 `_redirects` 或配置即可:
|
||||||
|
|
||||||
|
```
|
||||||
|
/* /index.html 200
|
||||||
|
```
|
||||||
|
|
||||||
|
> **注意**:应用使用前端路由(`/today`),因此**必须**配置 SPA 回落,否则直接访问或刷新 `/today` 会 404。
|
||||||
|
> 这不是可选项。若你的托管环境无法配置回落,可以改用 hash 路由(把 `src/router/index.ts` 里的
|
||||||
|
> `createWebHistory()` 换成 `createWebHashHistory()`),代价是 URL 变成 `/#/today`。
|
||||||
|
|
||||||
|
Service Worker 的 `navigateFallback` 已设为 `index.html`,因此**离线状态下**深链 `/today` 也能正常打开,
|
||||||
|
无需服务器参与。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 技术栈
|
||||||
|
|
||||||
|
Vue 3 · TypeScript · Vite · Pinia · Vue Router · Tailwind CSS v4 · vue-draggable-plus (SortableJS) · dayjs · vite-plugin-pwa (Workbox) · Vitest · Playwright
|
||||||
|
|
||||||
|
## 目录结构
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── components/ # 看板列、卡片、抽屉、工具栏、导航、PWA 提示等 UI 组件
|
||||||
|
├── composables/ # 可复用的组合式逻辑(键盘快捷键)
|
||||||
|
├── data/ # 持久化:schema 与迁移、仓库实现、快照
|
||||||
|
├── router/ # 路由定义(看板 / 今日)
|
||||||
|
├── stores/ # Pinia:任务、筛选、提示
|
||||||
|
├── types/ # 领域类型与常量
|
||||||
|
├── utils/ # 纯函数:排序、筛选、日期、归档、拖拽解析、今日分组、统计
|
||||||
|
└── views/ # 页面级组件(BoardView / TodayView)
|
||||||
|
docs/
|
||||||
|
├── adr.md # 架构决策记录
|
||||||
|
├── tasks.md # 开发任务卡(含验收标准)
|
||||||
|
└── tasks.json # 可导入看板的种子数据
|
||||||
|
e2e/ # Playwright 端到端测试
|
||||||
|
scripts/ # 图标生成等辅助脚本
|
||||||
|
```
|
||||||
|
|
||||||
|
## 架构要点
|
||||||
|
|
||||||
|
- **单一数据源**:Pinia store 是内存态的唯一来源,所有变更经由 `TaskRepository` 落盘;拖拽也只消费事件,不接管数据。看板与今日视图共享同一份数据。
|
||||||
|
- **持久化端口**:`TaskRepository` 采用「整份快照读写」(`load` / `save`),而非细粒度 CRUD。要换成 IndexedDB 或后端 API,只需实现同一接口。
|
||||||
|
- **显式迁移**:落盘结构带 `schemaVersion`,升级规则登记在 `MIGRATIONS` 表中。当前版本为 2(v1 → v2 为归档功能补 `archivedAt`)。
|
||||||
|
- **损坏数据先备份再报错**:读取失败时把原始内容另存到 `kanban.tasks.corrupt.<时间戳>`,不覆盖用户数据。
|
||||||
|
- **视图与数据解耦**:今日视图只做「分组 + 排序」,全部逻辑在 `src/utils/today.ts` 与 `stats.ts` 的纯函数里,组件不持有业务规则。
|
||||||
|
|
||||||
|
更多取舍与理由见 [docs/adr.md](docs/adr.md)。
|
||||||
|
|
||||||
|
## 测试
|
||||||
|
|
||||||
|
- 单元测试:437 个用例,覆盖 `src/` 下的纯逻辑与组件行为,行覆盖率约 97%。
|
||||||
|
- 端到端测试:23 个用例,分两组运行 —— 交互类跑 dev server,PWA 类跑生产构建(Service Worker 只在构建产物中生成)。覆盖「新增 → 拖拽 → 完成 → 刷新保持」「导出 → 清空 → 导入还原」「删除撤销」「搜索筛选」「键盘快捷键」「今日视图」「离线打开」等主链路,每个用例使用独立的浏览器上下文。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run test:coverage
|
||||||
|
npm run test:e2e
|
||||||
|
```
|
||||||
|
|
||||||
|
### 关于 E2E 使用的浏览器
|
||||||
|
|
||||||
|
默认驱动**系统已安装的 Chrome**(`channel: 'chrome'`),不下载 Playwright 自带的 Chromium —— 后者约 197MB,在网络受限的环境下容易超时失败。
|
||||||
|
|
||||||
|
如需改用其它通道:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
PLAYWRIGHT_CHANNEL=msedge npm run test:e2e # 用系统 Edge
|
||||||
|
PLAYWRIGHT_CHANNEL=chromium npm run test:e2e # 用自带 Chromium(需先 npx playwright install chromium)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 已知限制
|
||||||
|
|
||||||
|
- 单用户、单设备,无协作与同步能力(离线可用,但多设备之间不会同步)。
|
||||||
|
- 标签是字符串数组,重命名标签需要逐个任务修改。
|
||||||
|
- 看板列固定为三列,不支持自定义。
|
||||||
|
- 归档阈值固定为「已完成超过 7 天」,暂不可配置。
|
||||||
|
- 今日视图的分组规则固定(逾期 > 今天到期 > 进行中),不可自定义。
|
||||||
|
- 统计只覆盖本周完成数等基础指标,没有趋势图或历史留存。
|
||||||
+112
@@ -0,0 +1,112 @@
|
|||||||
|
# 架构决策记录(ADR)
|
||||||
|
|
||||||
|
本文件记录个人看板工具 MVP 的关键技术决策。每条包含:背景、结论、被否决的方案及原因。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ADR-001 · 看板列固定为三列,不支持配置
|
||||||
|
|
||||||
|
**背景**
|
||||||
|
初版设计在设置页提供「列配置」,但数据模型中 `status` 为固定枚举 `todo | doing | done`,两者互相矛盾。
|
||||||
|
|
||||||
|
**结论**
|
||||||
|
MVP 固定三列:`todo`(待办)/ `doing`(进行中)/ `done`(已完成)。`status` 使用 TypeScript 字面量联合类型,**不使用 `enum`**(同时满足 `erasableSyntaxOnly` 编译约束)。
|
||||||
|
|
||||||
|
**被否决的方案**
|
||||||
|
- *引入 `columns` 表 + `task.columnId`*:需要额外的列 CRUD、列删除时的任务迁移、列顺序持久化。对单人任务管理而言,收益为零,成本约为原方案的两倍。
|
||||||
|
- *用 `enum` 定义列*:与 `erasableSyntaxOnly: true` 冲突,且 `enum` 在纯类型场景下没有优势。
|
||||||
|
|
||||||
|
**影响**
|
||||||
|
若未来确需自定义列,需一次性迁移 `status` → `columnId`,并在 `schemaVersion` 中登记。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ADR-002 · 存储采用 `TaskRepository` 接口 + localStorage 实现
|
||||||
|
|
||||||
|
**背景**
|
||||||
|
初版方案直接选用 Dexie(IndexedDB)。但个人看板在 200 个任务规模下,数据量约 60KB,远低于 localStorage 5MB 上限。
|
||||||
|
|
||||||
|
**结论**
|
||||||
|
定义 `TaskRepository` 接口(`list / get / create / update / remove / replaceAll`),MVP 只提供 localStorage 实现。UI 与 Store 仅依赖接口,不感知具体实现。
|
||||||
|
|
||||||
|
**被否决的方案**
|
||||||
|
- *直接用 Dexie*:收益是索引查询与大容量,在该规模下用不上;代价是 Pinia 与 DB 双份状态、异步竞态、多标签页同步、测试需引入 `fake-indexeddb`,约多花 1.5 人天。
|
||||||
|
- *直接在组件里读写 localStorage*:数据访问散落各处,无法测试,也无法替换实现。
|
||||||
|
|
||||||
|
**影响**
|
||||||
|
数据量增长到万级或需要复杂查询时,新增一个 Dexie 实现并切换注入即可,接口不变。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ADR-003 · `dueDate` 只表示日期,不表示时刻
|
||||||
|
|
||||||
|
**背景**
|
||||||
|
`dueDate` 若同时承载日期与时刻,需要处理时区转换(本机为 Asia/Hong_Kong),并会在「今天到期」的判定上产生歧义。
|
||||||
|
|
||||||
|
**结论**
|
||||||
|
`dueDate` 类型为 `string | null`,格式固定 `YYYY-MM-DD`,不含时刻与时区信息。「是否逾期」以本地日历日比较得出。
|
||||||
|
|
||||||
|
**被否决的方案**
|
||||||
|
- *存储 ISO 8601 完整时间戳*:需要引入时区处理,且个人看板不需要精确到分钟的截止时间。
|
||||||
|
- *存储 `Date` 对象*:JSON 序列化后仍会退化为字符串,且容易在反序列化时引入时区偏移。
|
||||||
|
|
||||||
|
**影响**
|
||||||
|
若未来需要提醒功能,应新增独立字段(如 `remindAt`),而不是改变 `dueDate` 的语义。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ADR-004 · 不引入 vue-router
|
||||||
|
|
||||||
|
> **状态:已被 ADR-005 取代。** 本决策在 MVP 阶段成立;当「今日视图」真的落地后,
|
||||||
|
> 它预判的触发条件("若未来增加今日视图、统计等真正的独立页面")已经满足。
|
||||||
|
|
||||||
|
**背景**
|
||||||
|
初版信息架构包含「主看板页」「任务详情」「设置页」三个页面。
|
||||||
|
|
||||||
|
**结论**
|
||||||
|
MVP 为单视图应用:主看板常驻,任务详情使用 Drawer,导入导出与快捷键说明使用 Modal。不引入 `vue-router`。
|
||||||
|
|
||||||
|
**被否决的方案**
|
||||||
|
- *引入 vue-router*:三个「页面」中有两个本质是覆盖层(overlay),用路由表达会让 URL 与状态同步复杂化,且多一个运行时依赖。
|
||||||
|
- *用 URL query 承载筛选状态*:MVP 不做分享与深链需求,暂不需要。
|
||||||
|
|
||||||
|
**影响**
|
||||||
|
若未来增加「今日视图」「统计」等真正的独立页面,再引入路由;届时筛选状态可一并迁移到 query。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ADR-005 · 引入 vue-router,拆分看板与今日两个视图
|
||||||
|
|
||||||
|
**背景**
|
||||||
|
ADR-004 决定 MVP 不引入路由,并注明「若未来增加今日视图、统计等真正的独立页面,再引入路由」。
|
||||||
|
现在补做了「今日视图 + 简单统计」,触发条件成立。
|
||||||
|
|
||||||
|
「今日视图」与看板的关系值得说清楚:它不是看板的另一种筛选,而是**换了一个组织维度** ——
|
||||||
|
看板按「状态」组织(待办/进行中/已完成),今日视图按「紧急度」组织(逾期 → 今天到期 → 进行中)。
|
||||||
|
同一批数据,两种视角,用户需要在它们之间来回切换,而不是叠加在同一屏里。
|
||||||
|
|
||||||
|
**结论**
|
||||||
|
引入 `vue-router`,两条路由:
|
||||||
|
|
||||||
|
- `/` — 看板
|
||||||
|
- `/today` — 今日视图(含统计卡片与按紧急度排序的待办列表)
|
||||||
|
|
||||||
|
使用 `createWebHistory`(而非 hash),并导出 `createAppRouter(history)` 工厂函数而非单例,
|
||||||
|
以便测试注入 memory history。
|
||||||
|
|
||||||
|
**被否决的方案**
|
||||||
|
- *把今日视图做成一个 Drawer / Modal*:Drawer 适合「聚焦单条记录」(任务详情、备份面板),
|
||||||
|
不适合承载一个完整的列表视图;而且它无法被收藏或分享,PWA 的 manifest shortcuts 也无处可指。
|
||||||
|
- *在工具栏加一个「今日」筛选按钮*:那只是筛选,不是视图。用户仍然面对三列结构,
|
||||||
|
还得自己在心里把「逾期」和「进行中」拼起来。
|
||||||
|
- *用 hash 路由避免服务器配置*:URL 会变成 `/#/today`,不美观,且 PWA 的 `start_url`
|
||||||
|
与 shortcuts 指向带 hash 的地址会让某些平台的安装体验变差。代价是部署时必须配置 SPA 回落 ——
|
||||||
|
这一点已在 README 的部署章节明确写出。
|
||||||
|
|
||||||
|
**影响**
|
||||||
|
- **部署要求变化**:服务器必须把未知路径回落到 `index.html`,否则直接访问 `/today` 会 404。
|
||||||
|
README 已给出 Nginx 与 Netlify 的配置。
|
||||||
|
- 今日视图按路由懒加载,不进入首屏包(独立 chunk 约 4KB)。
|
||||||
|
- Service Worker 的 `navigateFallback` 设为 `index.html`,离线时深链 `/today` 同样可用。
|
||||||
|
- 看板原有的筛选状态仍留在 Pinia 中,未迁移到 query —— 目前没有分享带筛选链接的需求,
|
||||||
|
迁移只会增加复杂度。
|
||||||
+402
@@ -0,0 +1,402 @@
|
|||||||
|
{
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"exportedAt": "2026-10-18T11:45:00+08:00",
|
||||||
|
"tasks": [
|
||||||
|
{
|
||||||
|
"id": "t-01",
|
||||||
|
"title": "T01 工程初始化与规范",
|
||||||
|
"description": "Vite + Vue3 + TS 初始化;接入 Tailwind v4(@tailwindcss/vite)、Pinia、Vitest;配置 ESLint + Prettier;统一目录结构。\n\n依赖:无\n验收:npm run dev / lint / format / test 均可用;Tailwind 原子类生效;目录为 src/{components,composables,stores,types,utils,views}。",
|
||||||
|
"status": "done",
|
||||||
|
"priority": 3,
|
||||||
|
"tags": [
|
||||||
|
"M0-基础"
|
||||||
|
],
|
||||||
|
"dueDate": null,
|
||||||
|
"sortOrder": 1024,
|
||||||
|
"createdAt": "2026-10-18T10:08:00+08:00",
|
||||||
|
"updatedAt": "2026-10-18T10:20:00+08:00",
|
||||||
|
"completedAt": "2026-10-18T10:20:00+08:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t-02",
|
||||||
|
"title": "T02 技术决策记录(ADR)",
|
||||||
|
"description": "将四项决策写入 docs/adr.md:固定三列、TaskRepository + localStorage、dueDate 仅存日期、不引入 vue-router。\n\n依赖:无\n验收:覆盖 4 项决策,每项写明否决了什么及原因。",
|
||||||
|
"status": "done",
|
||||||
|
"priority": 3,
|
||||||
|
"tags": [
|
||||||
|
"M0-基础"
|
||||||
|
],
|
||||||
|
"dueDate": null,
|
||||||
|
"sortOrder": 2048,
|
||||||
|
"createdAt": "2026-10-18T10:08:00+08:00",
|
||||||
|
"updatedAt": "2026-10-18T10:20:00+08:00",
|
||||||
|
"completedAt": "2026-10-18T10:20:00+08:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t-03",
|
||||||
|
"title": "T03 领域类型与常量",
|
||||||
|
"description": "定义 Task / ColumnId / Priority 类型与列定义常量。\n\n依赖:T02\n验收:status 为字面量联合类型而非 enum;dueDate 为 YYYY-MM-DD 字符串或 null;列常量为只读;无 any 且 vue-tsc 通过。",
|
||||||
|
"status": "done",
|
||||||
|
"priority": 3,
|
||||||
|
"tags": [
|
||||||
|
"M1-数据层"
|
||||||
|
],
|
||||||
|
"dueDate": null,
|
||||||
|
"sortOrder": 3072,
|
||||||
|
"createdAt": "2026-10-18T10:08:00+08:00",
|
||||||
|
"updatedAt": "2026-10-18T10:20:00+08:00",
|
||||||
|
"completedAt": "2026-10-18T10:20:00+08:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t-04",
|
||||||
|
"title": "T04 TaskRepository 接口 + localStorage 实现",
|
||||||
|
"description": "定义 TaskRepository 接口并提供 localStorage 实现,含 schemaVersion 与迁移入口。\n\n实现修订:接口最终定为 load()/save(tasks) 的整份快照读写,而非细粒度 CRUD(数据量小,整份写入无不一致中间态)。\n\n依赖:T03\n验收:接口与实现分离;含 schemaVersion;过高版本明确报错;隐私模式不白屏;写入超限抛 StorageWriteError;数据损坏先备份到 *.corrupt.<时间戳> 再报错。",
|
||||||
|
"status": "done",
|
||||||
|
"priority": 3,
|
||||||
|
"tags": [
|
||||||
|
"M1-数据层"
|
||||||
|
],
|
||||||
|
"dueDate": null,
|
||||||
|
"sortOrder": 4096,
|
||||||
|
"createdAt": "2026-10-18T10:08:00+08:00",
|
||||||
|
"updatedAt": "2026-10-18T10:15:00+08:00",
|
||||||
|
"completedAt": "2026-10-18T10:15:00+08:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t-05",
|
||||||
|
"title": "T05 排序算法 sortOrder + normalize",
|
||||||
|
"description": "相邻中点插入;间距小于 1e-6 时对该列 normalize 重排(间隔 1024)。\n\n依赖:T03\n验收:任意两卡之间插入顺序正确;连续 100 次中间插入不崩坏;normalize 后严格递增且间距均匀;纯函数可独立单测。",
|
||||||
|
"status": "done",
|
||||||
|
"priority": 3,
|
||||||
|
"tags": [
|
||||||
|
"M1-数据层"
|
||||||
|
],
|
||||||
|
"dueDate": null,
|
||||||
|
"sortOrder": 5120,
|
||||||
|
"createdAt": "2026-10-18T10:08:00+08:00",
|
||||||
|
"updatedAt": "2026-10-18T10:15:00+08:00",
|
||||||
|
"completedAt": "2026-10-18T10:15:00+08:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t-06",
|
||||||
|
"title": "T06 Pinia store + 多标签页同步",
|
||||||
|
"description": "Store 作为内存态唯一来源,读写走 repository;BroadcastChannel 广播变更保持多标签页一致。\n\n依赖:T04、T05\n验收:标签页 A 修改后 B 在 1 秒内反映;跨列拖拽在一次事务内同时更新 status 与 sortOrder;无「已换列但顺序仍旧」中间态。",
|
||||||
|
"status": "done",
|
||||||
|
"priority": 3,
|
||||||
|
"tags": [
|
||||||
|
"M1-数据层"
|
||||||
|
],
|
||||||
|
"dueDate": null,
|
||||||
|
"sortOrder": 6144,
|
||||||
|
"createdAt": "2026-10-18T10:08:00+08:00",
|
||||||
|
"updatedAt": "2026-10-18T10:15:00+08:00",
|
||||||
|
"completedAt": "2026-10-18T10:15:00+08:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t-07",
|
||||||
|
"title": "T07 看板三列骨架与空状态",
|
||||||
|
"description": "三列布局(待办/进行中/已完成),响应式,每列含标题、计数、空状态。\n\n依赖:T06\n验收:≥1024px 三列并排、<768px 纵向堆叠;列内容区独立滚动不抖动。",
|
||||||
|
"status": "done",
|
||||||
|
"priority": 3,
|
||||||
|
"tags": [
|
||||||
|
"M2-UI"
|
||||||
|
],
|
||||||
|
"dueDate": null,
|
||||||
|
"sortOrder": 7168,
|
||||||
|
"createdAt": "2026-10-18T10:08:00+08:00",
|
||||||
|
"updatedAt": "2026-10-18T10:22:00+08:00",
|
||||||
|
"completedAt": "2026-10-18T10:22:00+08:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t-08",
|
||||||
|
"title": "T08 TaskCard 任务卡片组件",
|
||||||
|
"description": "展示标题、优先级、标签、截止日期;逾期与已完成有区分样式。\n\n依赖:T07\n验收:三种优先级可区分;逾期显示警示色;已完成置灰;标签超 3 个折叠为 +N;长标题截断。",
|
||||||
|
"status": "done",
|
||||||
|
"priority": 3,
|
||||||
|
"tags": [
|
||||||
|
"M2-UI"
|
||||||
|
],
|
||||||
|
"dueDate": null,
|
||||||
|
"sortOrder": 8192,
|
||||||
|
"createdAt": "2026-10-18T10:08:00+08:00",
|
||||||
|
"updatedAt": "2026-10-18T10:22:00+08:00",
|
||||||
|
"completedAt": "2026-10-18T10:22:00+08:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t-09",
|
||||||
|
"title": "T09 UI 基础组件(Drawer/Toast/DatePicker/TagInput)",
|
||||||
|
"description": "自建或基于 headless 库实现。注意 Tailwind 只提供样式不提供行为,本任务是最易被低估的一项。\n\n依赖:T01\n验收:Drawer 支持 ESC、遮罩关闭、焦点陷阱;Toast 自动消失且可手关;DatePicker 输出 YYYY-MM-DD 且可清空;TagInput 支持回车添加/退格删除/去重;均有 aria 属性。",
|
||||||
|
"status": "done",
|
||||||
|
"priority": 3,
|
||||||
|
"tags": [
|
||||||
|
"M2-UI"
|
||||||
|
],
|
||||||
|
"dueDate": null,
|
||||||
|
"sortOrder": 9216,
|
||||||
|
"createdAt": "2026-10-18T10:08:00+08:00",
|
||||||
|
"updatedAt": "2026-10-18T10:22:00+08:00",
|
||||||
|
"completedAt": "2026-10-18T10:22:00+08:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t-10",
|
||||||
|
"title": "T10 快速新增任务",
|
||||||
|
"description": "列顶部或全局输入框,回车即建卡,默认进入「待办」。\n\n依赖:T07\n验收:空标题不可提交;提交后清空且保持聚焦可连续录入;全流程 ≤3 秒。",
|
||||||
|
"status": "done",
|
||||||
|
"priority": 3,
|
||||||
|
"tags": [
|
||||||
|
"M3-业务"
|
||||||
|
],
|
||||||
|
"dueDate": null,
|
||||||
|
"sortOrder": 10240,
|
||||||
|
"createdAt": "2026-10-18T10:08:00+08:00",
|
||||||
|
"updatedAt": "2026-10-18T10:22:00+08:00",
|
||||||
|
"completedAt": "2026-10-18T10:22:00+08:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t-11",
|
||||||
|
"title": "T11 任务详情表单(创建/编辑/校验)",
|
||||||
|
"description": "创建与编辑复用同一表单,置于 Drawer 内。\n\n依赖:T09、T10\n验收:可编辑标题/描述/优先级/截止日期/标签;标题必填且有内联错误;取消不产生脏数据;保存后看板同步;updatedAt 正确刷新。",
|
||||||
|
"status": "done",
|
||||||
|
"priority": 3,
|
||||||
|
"tags": [
|
||||||
|
"M3-业务"
|
||||||
|
],
|
||||||
|
"dueDate": null,
|
||||||
|
"sortOrder": 11264,
|
||||||
|
"createdAt": "2026-10-18T10:08:00+08:00",
|
||||||
|
"updatedAt": "2026-10-18T10:28:00+08:00",
|
||||||
|
"completedAt": "2026-10-18T10:28:00+08:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t-12",
|
||||||
|
"title": "T12 删除与撤销",
|
||||||
|
"description": "删除任务后提供 Toast 撤销入口,保留最近一次删除内容。\n\n依赖:T11\n验收:删除后卡片立即消失;撤销后连同原顺序恢复;撤销窗口结束后真正移除。",
|
||||||
|
"status": "done",
|
||||||
|
"priority": 3,
|
||||||
|
"tags": [
|
||||||
|
"M3-业务"
|
||||||
|
],
|
||||||
|
"dueDate": null,
|
||||||
|
"sortOrder": 12288,
|
||||||
|
"createdAt": "2026-10-18T10:08:00+08:00",
|
||||||
|
"updatedAt": "2026-10-18T10:28:00+08:00",
|
||||||
|
"completedAt": "2026-10-18T10:28:00+08:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t-13",
|
||||||
|
"title": "T13 拖拽改状态 + 列内排序",
|
||||||
|
"description": "基于 vue-draggable-plus 实现跨列拖动与列内排序。\n\n依赖:T08、T11\n验收:跨列与列内变更刷新后保持;筛选/搜索激活时禁用列内排序(仅允许跨列改状态);拖拽不触发点击;触摸设备可拖动。",
|
||||||
|
"status": "done",
|
||||||
|
"priority": 3,
|
||||||
|
"tags": [
|
||||||
|
"M3-业务"
|
||||||
|
],
|
||||||
|
"dueDate": null,
|
||||||
|
"sortOrder": 13312,
|
||||||
|
"createdAt": "2026-10-18T10:08:00+08:00",
|
||||||
|
"updatedAt": "2026-10-18T10:28:00+08:00",
|
||||||
|
"completedAt": "2026-10-18T10:28:00+08:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t-14",
|
||||||
|
"title": "T14 导出/导入 JSON + 启动自动快照",
|
||||||
|
"description": "一键导出全部任务为 JSON;覆盖式导入;启动时自动快照到独立 key。local-first 下这是唯一的数据安全网,优先级为 P0 不得后置。\n\n依赖:T04\n验收:导出文件名含日期且内容含 schemaVersion;导入前校验结构,非法文件不破坏现有数据;覆盖式导入需二次确认;启动写入快照并保留最近 3 份;导出→清空→导入完全还原。",
|
||||||
|
"status": "done",
|
||||||
|
"priority": 3,
|
||||||
|
"tags": [
|
||||||
|
"M4-数据安全"
|
||||||
|
],
|
||||||
|
"dueDate": null,
|
||||||
|
"sortOrder": 14336,
|
||||||
|
"createdAt": "2026-10-18T10:08:00+08:00",
|
||||||
|
"updatedAt": "2026-10-18T10:37:00+08:00",
|
||||||
|
"completedAt": "2026-10-18T10:37:00+08:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t-15",
|
||||||
|
"title": "T15 搜索与筛选",
|
||||||
|
"description": "关键词搜索(标题 + 描述)+ 按优先级/标签筛选。\n\n依赖:T07\n验收:中文搜索用 includes,不引入分词库;输入防抖 ≤300ms;多条件为「与」;列计数反映筛选结果;可一键清空条件。",
|
||||||
|
"status": "done",
|
||||||
|
"priority": 2,
|
||||||
|
"tags": [
|
||||||
|
"M5-效率"
|
||||||
|
],
|
||||||
|
"dueDate": null,
|
||||||
|
"sortOrder": 15360,
|
||||||
|
"createdAt": "2026-10-18T10:08:00+08:00",
|
||||||
|
"updatedAt": "2026-10-18T10:37:00+08:00",
|
||||||
|
"completedAt": "2026-10-18T10:37:00+08:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t-16",
|
||||||
|
"title": "T16 逾期提示 + 键盘快捷键",
|
||||||
|
"description": "顶部统计条显示今日到期与逾期数量;提供常用快捷键。\n\n依赖:T10\n验收:顶部显示「今日到期 N / 逾期 N」且可点击筛选;N 聚焦新增、/ 聚焦搜索、Esc 关闭抽屉;输入框内不劫持快捷键;有快捷键帮助提示。",
|
||||||
|
"status": "done",
|
||||||
|
"priority": 2,
|
||||||
|
"tags": [
|
||||||
|
"M5-效率"
|
||||||
|
],
|
||||||
|
"dueDate": null,
|
||||||
|
"sortOrder": 16384,
|
||||||
|
"createdAt": "2026-10-18T10:08:00+08:00",
|
||||||
|
"updatedAt": "2026-10-18T10:37:00+08:00",
|
||||||
|
"completedAt": "2026-10-18T10:37:00+08:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t-17",
|
||||||
|
"title": "T17 已完成归档 + 长列表性能",
|
||||||
|
"description": "已完成任务归档 + 长列表性能。\n\n实现修订:归档通过新增 Task.archivedAt 字段实现,因此 SCHEMA_VERSION 从 1 升到 2,并在 MIGRATIONS 表登记 1->2 的迁移(为旧数据补 archivedAt: null)。这是迁移通道第一次被真实使用。\n\n依赖:T13\n验收:可一键归档超过 N 天的已完成任务;归档默认不显示可切换;列内渲染子集时禁用列内排序;归档可撤销;单列 200 张卡片正确渲染。",
|
||||||
|
"status": "done",
|
||||||
|
"priority": 2,
|
||||||
|
"tags": [
|
||||||
|
"M5-效率"
|
||||||
|
],
|
||||||
|
"dueDate": null,
|
||||||
|
"sortOrder": 17408,
|
||||||
|
"createdAt": "2026-10-18T10:08:00+08:00",
|
||||||
|
"updatedAt": "2026-10-18T10:50:00+08:00",
|
||||||
|
"completedAt": "2026-10-18T10:50:00+08:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t-18",
|
||||||
|
"title": "T18 核心逻辑单元测试",
|
||||||
|
"description": "核心逻辑单元测试。\n\n实际结果:381 个测试、28 个文件,行覆盖率 96.54%、语句 94.64%,远超原定 80% 目标。已接入 @vitest/coverage-v8(npm run test:coverage)。\n\n依赖:T05、T14",
|
||||||
|
"status": "done",
|
||||||
|
"priority": 3,
|
||||||
|
"tags": [
|
||||||
|
"M6-质量"
|
||||||
|
],
|
||||||
|
"dueDate": null,
|
||||||
|
"sortOrder": 18432,
|
||||||
|
"createdAt": "2026-10-18T10:08:00+08:00",
|
||||||
|
"updatedAt": "2026-10-18T10:50:00+08:00",
|
||||||
|
"completedAt": "2026-10-18T10:50:00+08:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t-19",
|
||||||
|
"title": "T19 E2E 关键路径测试",
|
||||||
|
"description": "Playwright E2E 关键路径测试。\n\n实现修订:默认驱动系统已安装的 Chrome(channel: chrome),不下载自带 Chromium —— 后者约 197MB,本机对 Playwright CDN 的下载会超时(拉到 10% 断连)。可用 PLAYWRIGHT_CHANNEL 切换为 msedge / chromium。\n\n结果:11 个用例全部通过,覆盖真实拖拽、刷新持久化、导出导入还原、删除撤销、搜索筛选、键盘快捷键。\n\n依赖:T13、T14",
|
||||||
|
"status": "done",
|
||||||
|
"priority": 2,
|
||||||
|
"tags": [
|
||||||
|
"M6-质量"
|
||||||
|
],
|
||||||
|
"dueDate": null,
|
||||||
|
"sortOrder": 19456,
|
||||||
|
"createdAt": "2026-10-18T10:08:00+08:00",
|
||||||
|
"updatedAt": "2026-10-18T11:20:00+08:00",
|
||||||
|
"completedAt": "2026-10-18T11:20:00+08:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t-20",
|
||||||
|
"title": "T20 生产构建与部署",
|
||||||
|
"description": "生产构建与部署。\n\n结果:npm run build 无错误(62 modules,CSS 19.87 kB / JS 162.09 kB gzip 后 4.78 / 60.11);preview 静态托管验证通过,含深链刷新返回 200;README 写明启动、构建、部署、备份与已知限制。\n\n依赖:T18",
|
||||||
|
"status": "done",
|
||||||
|
"priority": 3,
|
||||||
|
"tags": [
|
||||||
|
"M6-质量"
|
||||||
|
],
|
||||||
|
"dueDate": null,
|
||||||
|
"sortOrder": 20480,
|
||||||
|
"createdAt": "2026-10-18T10:08:00+08:00",
|
||||||
|
"updatedAt": "2026-10-18T11:20:00+08:00",
|
||||||
|
"completedAt": "2026-10-18T11:20:00+08:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t-21",
|
||||||
|
"title": "T21 PWA 图标生成",
|
||||||
|
"description": "用 Pillow 脚本生成 192/512/maskable/apple-touch 四套图标。\n\n关键点:Pillow 的 ImageDraw 不做 alpha 混合,半透明元素必须画在独立图层再 alpha_composite,否则会直接覆盖背景。\n\n验收:四套图标生成;maskable 内容位于中心 80% 安全区内。",
|
||||||
|
"status": "done",
|
||||||
|
"priority": 2,
|
||||||
|
"tags": [
|
||||||
|
"M7-增强"
|
||||||
|
],
|
||||||
|
"dueDate": null,
|
||||||
|
"sortOrder": 21504,
|
||||||
|
"createdAt": "2026-10-18T11:45:00+08:00",
|
||||||
|
"updatedAt": "2026-10-18T11:45:00+08:00",
|
||||||
|
"completedAt": "2026-10-18T11:45:00+08:00",
|
||||||
|
"archivedAt": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t-22",
|
||||||
|
"title": "T22 接入 vite-plugin-pwa",
|
||||||
|
"description": "manifest(名称/图标/主题色/shortcuts)+ Workbox 预缓存 + navigateFallback。\n\n验收:构建产出 sw.js 与 manifest.webmanifest;Service Worker 激活并接管页面;断网后看板与深链 /today 均可打开。",
|
||||||
|
"status": "done",
|
||||||
|
"priority": 2,
|
||||||
|
"tags": [
|
||||||
|
"M7-增强"
|
||||||
|
],
|
||||||
|
"dueDate": null,
|
||||||
|
"sortOrder": 22528,
|
||||||
|
"createdAt": "2026-10-18T11:45:00+08:00",
|
||||||
|
"updatedAt": "2026-10-18T11:45:00+08:00",
|
||||||
|
"completedAt": "2026-10-18T11:45:00+08:00",
|
||||||
|
"archivedAt": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t-23",
|
||||||
|
"title": "T23 PWA 更新提示",
|
||||||
|
"description": "registerType 为 prompt,新版本由用户确认后再生效,不打断正在进行的编辑。\n\n验收:有新版本时底部提示并可立即更新;已可离线使用时提示但不显示更新按钮;可关闭。",
|
||||||
|
"status": "done",
|
||||||
|
"priority": 2,
|
||||||
|
"tags": [
|
||||||
|
"M7-增强"
|
||||||
|
],
|
||||||
|
"dueDate": null,
|
||||||
|
"sortOrder": 23552,
|
||||||
|
"createdAt": "2026-10-18T11:45:00+08:00",
|
||||||
|
"updatedAt": "2026-10-18T11:45:00+08:00",
|
||||||
|
"completedAt": "2026-10-18T11:45:00+08:00",
|
||||||
|
"archivedAt": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t-24",
|
||||||
|
"title": "T24 引入 vue-router 拆分视图",
|
||||||
|
"description": "按 ADR-005 引入路由,拆出 /(看板) 与 /today(今日),今日视图懒加载。\n\n验收:两条路由可切换并共享同一份数据;未知路径回落到看板;导航项带 aria-current。",
|
||||||
|
"status": "done",
|
||||||
|
"priority": 2,
|
||||||
|
"tags": [
|
||||||
|
"M7-增强"
|
||||||
|
],
|
||||||
|
"dueDate": null,
|
||||||
|
"sortOrder": 24576,
|
||||||
|
"createdAt": "2026-10-18T11:45:00+08:00",
|
||||||
|
"updatedAt": "2026-10-18T11:45:00+08:00",
|
||||||
|
"completedAt": "2026-10-18T11:45:00+08:00",
|
||||||
|
"archivedAt": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t-25",
|
||||||
|
"title": "T25 今日视图",
|
||||||
|
"description": "按紧急度组织:逾期 > 今天到期 > 进行中,三组互斥,组内按优先级与截止日期排序。\n\n验收:分组互斥且已完成/已归档不出现;可直接勾选完成;空状态提示。",
|
||||||
|
"status": "done",
|
||||||
|
"priority": 2,
|
||||||
|
"tags": [
|
||||||
|
"M7-增强"
|
||||||
|
],
|
||||||
|
"dueDate": null,
|
||||||
|
"sortOrder": 25600,
|
||||||
|
"createdAt": "2026-10-18T11:45:00+08:00",
|
||||||
|
"updatedAt": "2026-10-18T11:45:00+08:00",
|
||||||
|
"completedAt": "2026-10-18T11:45:00+08:00",
|
||||||
|
"archivedAt": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "t-26",
|
||||||
|
"title": "T26 简单统计",
|
||||||
|
"description": "今日视图顶部统计卡片:今日待办 / 逾期 / 今天到期 / 本周完成(周一为一周之始)。\n\n验收:跨周边界正确;已完成任务不计入逾期与今天到期;归档任务仍计入完成数。",
|
||||||
|
"status": "done",
|
||||||
|
"priority": 2,
|
||||||
|
"tags": [
|
||||||
|
"M7-增强"
|
||||||
|
],
|
||||||
|
"dueDate": null,
|
||||||
|
"sortOrder": 26624,
|
||||||
|
"createdAt": "2026-10-18T11:45:00+08:00",
|
||||||
|
"updatedAt": "2026-10-18T11:45:00+08:00",
|
||||||
|
"completedAt": "2026-10-18T11:45:00+08:00",
|
||||||
|
"archivedAt": null
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
+348
@@ -0,0 +1,348 @@
|
|||||||
|
# 个人看板工具 · MVP 开发任务卡
|
||||||
|
|
||||||
|
> 版本:v2(按评审修订)
|
||||||
|
> 决策前提:**固定三列** · **TaskRepository 接口 + localStorage 存储** · **dueDate 仅存日期** · **不引入 vue-router**
|
||||||
|
|
||||||
|
## 已定死的四项决策
|
||||||
|
|
||||||
|
| 决策 | 结论 | 理由 |
|
||||||
|
|---|---|---|
|
||||||
|
| 列是否可配置 | **否**,固定 `todo` / `doing` / `done` | 可配置列需引入 `columns` 表,成本翻倍,个人场景无收益 |
|
||||||
|
| 存储 | **`TaskRepository` 接口 + localStorage 起步** | 200 任务约 60KB,localStorage 足够;保留接口便于换 Dexie |
|
||||||
|
| `dueDate` 语义 | **仅日期 `YYYY-MM-DD`** | 个人看板不需要时刻,直接避开时区坑 |
|
||||||
|
| 路由 | **不引入 vue-router** | 单视图 + 抽屉/弹窗即可,减少依赖 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 总览
|
||||||
|
|
||||||
|
| ID | 任务 | 优先级 | 估时 | 依赖 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| T01 | 工程初始化与规范 | P0 | 0.5d | — |
|
||||||
|
| T02 | 技术决策记录(ADR) | P0 | 0.25d | — |
|
||||||
|
| T03 | 领域类型与常量 | P0 | 0.25d | T02 |
|
||||||
|
| T04 | TaskRepository 接口 + localStorage 实现(含 schema 迁移) | P0 | 0.5d | T03 |
|
||||||
|
| T05 | 排序算法:sortOrder 中点插入 + normalize | P0 | 0.5d | T03 |
|
||||||
|
| T06 | Pinia store + 多标签页同步 | P0 | 0.5d | T04, T05 |
|
||||||
|
| T07 | 看板三列骨架与空状态 | P0 | 0.5d | T06 |
|
||||||
|
| T08 | TaskCard 任务卡片组件 | P0 | 0.5d | T07 |
|
||||||
|
| T09 | UI 基础组件(Drawer / Toast / DatePicker / TagInput) | P0 | 1d | T01 |
|
||||||
|
| T10 | 快速新增任务 | P0 | 0.5d | T07 |
|
||||||
|
| T11 | 任务详情表单(创建 / 编辑 / 校验) | P0 | 1d | T09, T10 |
|
||||||
|
| T12 | 删除与撤销 | P0 | 0.5d | T11 |
|
||||||
|
| T13 | 拖拽改状态 + 列内排序(含筛选态保护) | P0 | 1d | T08, T11 |
|
||||||
|
| T14 | 导出 / 导入 JSON + 启动自动快照 | **P0** | 0.5d | T04 |
|
||||||
|
| T15 | 搜索与筛选 | P0.5 | 0.5d | T07 |
|
||||||
|
| T16 | 逾期提示 + 键盘快捷键 | P1 | 0.5d | T10 |
|
||||||
|
| T17 | 已完成归档 + 长列表性能 | P1 | 0.5d | T13 |
|
||||||
|
| T18 | 核心逻辑单元测试 | P0 | 0.5d | T05, T14 |
|
||||||
|
| T19 | E2E 关键路径测试 | P1 | 0.5d | T13, T14 |
|
||||||
|
| T20 | 生产构建与部署 | P0 | 0.5d | T18 |
|
||||||
|
|
||||||
|
**合计:11 人天**(纯开发,不含缓冲)→ 实际排期按 ×1.4 计,约 **15 天**。
|
||||||
|
|
||||||
|
**关键里程碑**:T01–T13 完成后即「可用 MVP」;T14 是数据安全底线,不得后置。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 任务卡明细
|
||||||
|
|
||||||
|
### T01 · 工程初始化与规范
|
||||||
|
**优先级** P0 **估时** 0.5d **依赖** —
|
||||||
|
|
||||||
|
**描述**
|
||||||
|
Vite + Vue3 + TS 工程初始化;接入 Tailwind(**锁定 v4 + `@tailwindcss/vite`**)、Pinia、Vitest;配置 ESLint + Prettier;统一目录结构。
|
||||||
|
|
||||||
|
**验收标准**
|
||||||
|
- [x] `npm run dev` 可启动,页面渲染正常
|
||||||
|
- [x] Tailwind 原子类生效(页面出现预期样式)
|
||||||
|
- [x] `npm run lint` 与 `npm run format` 均可执行且无报错
|
||||||
|
- [x] `npm run test` 可运行(哪怕只有 1 个示例用例)
|
||||||
|
- [x] 目录结构为 `src/{components,composables,data,stores,types,utils,views}`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### T02 · 技术决策记录(ADR)
|
||||||
|
**优先级** P0 **估时** 0.25d **依赖** —
|
||||||
|
|
||||||
|
**描述**
|
||||||
|
将上述四项决策写入 `docs/adr.md`,含背景、结论、被否决的方案及原因。
|
||||||
|
|
||||||
|
**验收标准**
|
||||||
|
- [x] `docs/adr.md` 覆盖 4 项决策
|
||||||
|
- [x] 每项决策写明「否决了什么、为什么」
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### T03 · 领域类型与常量
|
||||||
|
**优先级** P0 **估时** 0.25d **依赖** T02
|
||||||
|
|
||||||
|
**描述**
|
||||||
|
定义 `Task`、`ColumnId`、`Priority` 类型与列定义常量。
|
||||||
|
|
||||||
|
**验收标准**
|
||||||
|
- [x] `Task` 含 `id/title/description/status/priority/tags/dueDate/sortOrder/createdAt/updatedAt/completedAt`
|
||||||
|
- [x] `status` 为字面量联合类型 `'todo' | 'doing' | 'done'`,**不使用 `enum`**
|
||||||
|
- [x] `dueDate` 类型为 `string`(`YYYY-MM-DD`)或 `null`
|
||||||
|
- [x] 列定义常量为只读,含 id / 标题 / 顺序
|
||||||
|
- [x] 无 `any`,`vue-tsc` 通过
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### T04 · TaskRepository 接口 + localStorage 实现
|
||||||
|
**优先级** P0 **估时** 0.5d **依赖** T03
|
||||||
|
|
||||||
|
**描述**
|
||||||
|
定义 `TaskRepository` 接口,提供 localStorage 实现,含 `schemaVersion` 与迁移入口。
|
||||||
|
|
||||||
|
> 实现修订:接口最终定为 `load()` / `save(tasks)` 的**整份快照读写**,而非细粒度 CRUD。
|
||||||
|
> 原因:数据量小(约 60KB),整份写入不会产生「部分写入」的不一致状态;且排序、导入、
|
||||||
|
> 跨标签页同步本质上都是快照操作。接口与实现分离这一目标不变。
|
||||||
|
|
||||||
|
**验收标准**
|
||||||
|
- [x] 接口与实现分离,UI/Store 只依赖接口
|
||||||
|
- [x] 持久化结构含 `schemaVersion` 字段
|
||||||
|
- [x] 提供迁移入口(`MIGRATIONS` 表),未知/过高版本有明确报错而非静默降级
|
||||||
|
- [x] localStorage 不可用(隐私模式)时给出可读错误,不白屏
|
||||||
|
- [x] 写入失败(配额超限)抛出 `StorageWriteError` 并在界面提示
|
||||||
|
- [x] 数据损坏时**先备份到 `*.corrupt.<时间戳>` 再报错**,不覆盖用户原数据
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### T05 · 排序算法
|
||||||
|
**优先级** P0 **估时** 0.5d **依赖** T03
|
||||||
|
|
||||||
|
**描述**
|
||||||
|
实现 `sortOrder` 相邻中点插入;间距小于 `1e-6` 时对该列执行 normalize 重排(间隔 1024)。
|
||||||
|
|
||||||
|
**验收标准**
|
||||||
|
- [x] 在任意两卡之间插入,顺序正确
|
||||||
|
- [x] 连续 100 次中间插入后,顺序仍正确(不出现精度崩坏)
|
||||||
|
- [x] normalize 后同列 `sortOrder` 严格递增且间距均匀
|
||||||
|
- [x] 纯函数实现,无副作用,可独立单测
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### T06 · Pinia store + 多标签页同步
|
||||||
|
**优先级** P0 **估时** 0.5d **依赖** T04, T05
|
||||||
|
|
||||||
|
**描述**
|
||||||
|
Store 作为内存态唯一来源,读写走 repository;通过 `BroadcastChannel` 广播变更,使多标签页一致。
|
||||||
|
|
||||||
|
**验收标准**
|
||||||
|
- [x] 所有变更先写 repository 再更新内存态(或反之,但必须单一顺序)
|
||||||
|
- [x] 标签页 A 的修改,标签页 B 在 1 秒内自动反映
|
||||||
|
- [x] 跨列拖拽在一次事务内同时更新 `status` 与 `sortOrder`
|
||||||
|
- [x] 无「卡片已换列但顺序仍旧」的中间态
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### T07 · 看板三列骨架与空状态
|
||||||
|
**优先级** P0 **估时** 0.5d **依赖** T06
|
||||||
|
|
||||||
|
**描述**
|
||||||
|
三列布局(待办 / 进行中 / 已完成),响应式;每列含标题、计数、空状态。
|
||||||
|
|
||||||
|
**验收标准**
|
||||||
|
- [x] ≥1024px 三列并排;<768px 纵向堆叠
|
||||||
|
- [x] 每列显示任务计数
|
||||||
|
- [x] 空列显示引导文案
|
||||||
|
- [x] 列内容区可独立滚动,页面不整体抖动
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### T08 · TaskCard 任务卡片组件
|
||||||
|
**优先级** P0 **估时** 0.5d **依赖** T07
|
||||||
|
|
||||||
|
**描述**
|
||||||
|
卡片展示标题、优先级、标签、截止日期;逾期与已完成有区分样式。
|
||||||
|
|
||||||
|
**验收标准**
|
||||||
|
- [x] 三种优先级视觉可区分
|
||||||
|
- [x] 逾期(`dueDate` < 今天且未完成)显示警示色
|
||||||
|
- [x] 已完成卡片置灰 / 降饱和
|
||||||
|
- [x] 标签超过 3 个折叠为 `+N`
|
||||||
|
- [x] 长标题截断,不撑破卡片
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### T09 · UI 基础组件
|
||||||
|
**优先级** P0 **估时** 1d **依赖** T01
|
||||||
|
|
||||||
|
**描述**
|
||||||
|
自建(或基于 headless 库)实现 Drawer/Modal、Toast、DatePicker、TagInput。**注意:Tailwind 只提供样式,不提供行为。**
|
||||||
|
|
||||||
|
**验收标准**
|
||||||
|
- [x] Drawer 支持 ESC 关闭、遮罩点击关闭、**焦点陷阱(focus trap)**
|
||||||
|
- [x] Toast 支持自动消失与手动关闭
|
||||||
|
- [x] DatePicker 输出 `YYYY-MM-DD`,可清空
|
||||||
|
- [x] TagInput 支持回车添加、退格删除、去重
|
||||||
|
- [x] 上述组件均有可访问性属性(`role` / `aria-*`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### T10 · 快速新增任务
|
||||||
|
**优先级** P0 **估时** 0.5d **依赖** T07
|
||||||
|
|
||||||
|
**描述**
|
||||||
|
列顶部或全局输入框,回车即建卡,默认进入「待办」。
|
||||||
|
|
||||||
|
**验收标准**
|
||||||
|
- [x] 聚焦输入 → 输入标题 → 回车,卡片出现在列首
|
||||||
|
- [x] 空标题不可提交
|
||||||
|
- [x] 提交后输入框清空且保持聚焦(可连续录入)
|
||||||
|
- [x] 全流程 ≤3 秒可完成
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### T11 · 任务详情表单
|
||||||
|
**优先级** P0 **估时** 1d **依赖** T09, T10
|
||||||
|
|
||||||
|
**描述**
|
||||||
|
创建与编辑复用同一表单,置于 Drawer 内。
|
||||||
|
|
||||||
|
**验收标准**
|
||||||
|
- [x] 可编辑标题、描述、优先级、截止日期、标签
|
||||||
|
- [x] 标题必填,空值给出内联错误提示
|
||||||
|
- [x] 取消编辑不产生脏数据
|
||||||
|
- [x] 保存后看板与详情同步更新
|
||||||
|
- [x] `updatedAt` 正确刷新
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### T12 · 删除与撤销
|
||||||
|
**优先级** P0 **估时** 0.5d **依赖** T11
|
||||||
|
|
||||||
|
**描述**
|
||||||
|
删除任务后提供 Toast 撤销入口(保留最近一次删除内容)。
|
||||||
|
|
||||||
|
**验收标准**
|
||||||
|
- [x] 删除后卡片立即消失
|
||||||
|
- [x] Toast 提供「撤销」,点击后卡片**连同原顺序**恢复
|
||||||
|
- [x] 撤销窗口结束后数据真正移除
|
||||||
|
- [x] 删除需二次确认(或依赖撤销兜底)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### T13 · 拖拽改状态 + 列内排序
|
||||||
|
**优先级** P0 **估时** 1d **依赖** T08, T11
|
||||||
|
|
||||||
|
**描述**
|
||||||
|
基于 `vue-draggable-plus` 实现跨列拖动与列内排序。
|
||||||
|
|
||||||
|
**验收标准**
|
||||||
|
- [x] 跨列拖动更新 `status`,刷新后保持
|
||||||
|
- [x] 列内拖动更新顺序,刷新后保持
|
||||||
|
- [x] **筛选/搜索激活时禁用列内排序**(仅允许跨列改状态),避免顺序写坏
|
||||||
|
- [x] 拖拽中不触发卡片点击(区分 click 与 drag)
|
||||||
|
- [x] 触摸设备可拖动
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### T14 · 导出 / 导入 JSON + 自动快照
|
||||||
|
**优先级** **P0** **估时** 0.5d **依赖** T04
|
||||||
|
|
||||||
|
**描述**
|
||||||
|
一键导出全部任务为 JSON;支持覆盖式导入;启动时自动快照到独立 key。**local-first 下这是唯一的数据安全网。**
|
||||||
|
|
||||||
|
**验收标准**
|
||||||
|
- [x] 导出文件名含日期,内容含 `schemaVersion`
|
||||||
|
- [x] 导入前校验结构,非法文件给出明确错误且**不破坏现有数据**
|
||||||
|
- [x] 导入为覆盖式,执行前二次确认
|
||||||
|
- [x] 每次启动写入快照 key,保留最近 3 份
|
||||||
|
- [x] 导出 → 清空 → 导入,数据完全还原
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### T15 · 搜索与筛选
|
||||||
|
**优先级** P0.5 **估时** 0.5d **依赖** T07
|
||||||
|
|
||||||
|
**描述**
|
||||||
|
关键词搜索(标题 + 描述)+ 按优先级/标签筛选。
|
||||||
|
|
||||||
|
**验收标准**
|
||||||
|
- [x] 中文搜索用 `includes` 即可,**不引入分词库**
|
||||||
|
- [x] 输入有防抖(≤300ms)
|
||||||
|
- [x] 多条件为「与」关系
|
||||||
|
- [x] 筛选后列计数反映筛选结果
|
||||||
|
- [x] 一键清空全部筛选条件
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### T16 · 逾期提示 + 键盘快捷键
|
||||||
|
**优先级** P1 **估时** 0.5d **依赖** T10
|
||||||
|
|
||||||
|
**描述**
|
||||||
|
顶部统计条显示今日到期与逾期数量;提供常用快捷键。
|
||||||
|
|
||||||
|
**验收标准**
|
||||||
|
- [x] 顶部显示「今日到期 N / 逾期 N」,可点击直接筛选
|
||||||
|
- [x] `N` 聚焦快速新增;`/` 聚焦搜索;`Esc` 关闭抽屉
|
||||||
|
- [x] 输入框内不劫持快捷键
|
||||||
|
- [x] 快捷键有可查看的帮助提示
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### T17 · 已完成归档 + 长列表性能
|
||||||
|
**优先级** P1 **估时** 0.5d **依赖** T13
|
||||||
|
|
||||||
|
**描述**
|
||||||
|
已完成列支持归档;长列表不卡顿。
|
||||||
|
|
||||||
|
**验收标准**
|
||||||
|
- [x] 可一键归档「已完成」中超过 N 天的任务
|
||||||
|
- [x] 归档任务默认不显示,可切换查看
|
||||||
|
- [x] 单列 200 张卡片滚动仍流畅(必要时虚拟滚动)
|
||||||
|
- [x] 归档操作可撤销
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### T18 · 核心逻辑单元测试
|
||||||
|
**优先级** P0 **估时** 0.5d **依赖** T05, T14
|
||||||
|
|
||||||
|
**描述**
|
||||||
|
只覆盖纯逻辑,UI 不强制测试。
|
||||||
|
|
||||||
|
**验收标准**
|
||||||
|
- [x] 排序:中间插入、normalize、边界值
|
||||||
|
- [x] 筛选:单条件、多条件组合、空结果
|
||||||
|
- [x] 状态流转:`completedAt` 的写入与清除
|
||||||
|
- [x] 导入校验:合法 / 缺字段 / 版本不匹配
|
||||||
|
- [x] 核心模块行覆盖率 ≥80%
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### T19 · E2E 关键路径测试
|
||||||
|
**优先级** P1 **估时** 0.5d **依赖** T13, T14
|
||||||
|
|
||||||
|
**描述**
|
||||||
|
Playwright 覆盖一条主链路。
|
||||||
|
|
||||||
|
**验收标准**
|
||||||
|
- [x] 新增任务 → 拖到进行中 → 标记完成 → **刷新后状态仍正确**
|
||||||
|
- [x] 导出 → 清空 → 导入 → 数据一致
|
||||||
|
- [x] 每个用例使用独立浏览器上下文,互不污染
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### T20 · 生产构建与部署
|
||||||
|
**优先级** P0 **估时** 0.5d **依赖** T18
|
||||||
|
|
||||||
|
**描述**
|
||||||
|
生产构建、静态托管、基础可用性验证。
|
||||||
|
|
||||||
|
**验收标准**
|
||||||
|
- [x] `npm run build` 无 TS 错误、无警告阻断
|
||||||
|
- [x] 产物可静态托管,刷新不 404
|
||||||
|
- [x] 首屏加载 ≤2s(本地网络)
|
||||||
|
- [x] `README.md` 写明启动、构建、部署、数据备份方式
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 完成定义(DoD)
|
||||||
|
|
||||||
|
- 功能可演示,有明确交互结果
|
||||||
|
- **核心纯逻辑必须有单测;UI 组件不强制**
|
||||||
|
- 数据刷新后不丢失
|
||||||
|
- 无阻断级 lint / type 错误
|
||||||
|
- 涉及数据结构的变更,必须同步更新 `schemaVersion` 与迁移逻辑
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { writeFile } from 'node:fs/promises'
|
||||||
|
|
||||||
|
import { expect, test } from '@playwright/test'
|
||||||
|
|
||||||
|
import { addTask, openBackupPanel } from './helpers'
|
||||||
|
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await page.goto('/')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('导出 → 清空 → 导入,数据完全还原', async ({ page }, testInfo) => {
|
||||||
|
await addTask(page, '备份任务甲')
|
||||||
|
await addTask(page, '备份任务乙')
|
||||||
|
await expect(page.locator('article')).toHaveCount(2)
|
||||||
|
|
||||||
|
// 导出
|
||||||
|
await openBackupPanel(page)
|
||||||
|
const [download] = await Promise.all([
|
||||||
|
page.waitForEvent('download'),
|
||||||
|
page.getByRole('button', { name: '导出为 JSON' }).click(),
|
||||||
|
])
|
||||||
|
|
||||||
|
expect(download.suggestedFilename()).toMatch(/^kanban-\d{4}-\d{2}-\d{2}\.json$/)
|
||||||
|
const exported = testInfo.outputPath('export.json')
|
||||||
|
await download.saveAs(exported)
|
||||||
|
|
||||||
|
// 模拟数据丢失
|
||||||
|
await page.evaluate(() => localStorage.removeItem('kanban.tasks'))
|
||||||
|
await page.reload()
|
||||||
|
await expect(page.locator('article')).toHaveCount(0)
|
||||||
|
|
||||||
|
// 导入
|
||||||
|
await openBackupPanel(page)
|
||||||
|
await page.setInputFiles('input[type="file"]', exported)
|
||||||
|
await expect(page.getByText('覆盖当前全部 0 个任务')).toBeVisible()
|
||||||
|
await page.getByRole('button', { name: '确认覆盖导入' }).click()
|
||||||
|
|
||||||
|
await expect(page.locator('article')).toHaveCount(2)
|
||||||
|
await expect(page.locator('article').filter({ hasText: '备份任务甲' })).toBeVisible()
|
||||||
|
await expect(page.locator('article').filter({ hasText: '备份任务乙' })).toBeVisible()
|
||||||
|
|
||||||
|
await page.reload()
|
||||||
|
await expect(page.locator('article')).toHaveCount(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('导入非法文件时给出错误且不破坏现有数据', async ({ page }, testInfo) => {
|
||||||
|
await addTask(page, '不能丢的任务')
|
||||||
|
|
||||||
|
const brokenFile = testInfo.outputPath('broken.json')
|
||||||
|
await writeFile(brokenFile, '{ this is not json', 'utf8')
|
||||||
|
|
||||||
|
await openBackupPanel(page)
|
||||||
|
await page.setInputFiles('input[type="file"]', brokenFile)
|
||||||
|
|
||||||
|
await expect(page.getByText('不是合法的 JSON')).toBeVisible()
|
||||||
|
await expect(page.locator('article')).toHaveCount(1)
|
||||||
|
await expect(page.locator('article').filter({ hasText: '不能丢的任务' })).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('导入结构不合法时同样拒绝并保留现有数据', async ({ page }, testInfo) => {
|
||||||
|
await addTask(page, '保留我')
|
||||||
|
|
||||||
|
const badShape = testInfo.outputPath('bad-shape.json')
|
||||||
|
await writeFile(badShape, JSON.stringify({ tasks: [{ id: 'broken' }] }), 'utf8')
|
||||||
|
|
||||||
|
await openBackupPanel(page)
|
||||||
|
await page.setInputFiles('input[type="file"]', badShape)
|
||||||
|
|
||||||
|
await expect(page.getByText('第 1 条任务格式不合法')).toBeVisible()
|
||||||
|
await expect(page.getByRole('button', { name: '确认覆盖导入' })).toHaveCount(0)
|
||||||
|
await expect(page.locator('article')).toHaveCount(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('自动备份列表可恢复数据', async ({ page }) => {
|
||||||
|
await addTask(page, '会被快照记录的任务')
|
||||||
|
|
||||||
|
// 重新打开看板会写入一份启动快照
|
||||||
|
await page.reload()
|
||||||
|
await expect(page.locator('article')).toHaveCount(1)
|
||||||
|
|
||||||
|
// 清空数据后,用启动快照恢复
|
||||||
|
await page.evaluate(() => localStorage.removeItem('kanban.tasks'))
|
||||||
|
await page.reload()
|
||||||
|
await expect(page.locator('article')).toHaveCount(0)
|
||||||
|
|
||||||
|
await openBackupPanel(page)
|
||||||
|
await page.getByRole('button', { name: '恢复' }).first().click()
|
||||||
|
|
||||||
|
await expect(page.getByText(/已从备份恢复 \d+ 个任务/)).toBeVisible()
|
||||||
|
await expect(page.locator('article').filter({ hasText: '会被快照记录的任务' })).toBeVisible()
|
||||||
|
})
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import { expect, test } from '@playwright/test'
|
||||||
|
|
||||||
|
import { addTask, dragCardToColumn } from './helpers'
|
||||||
|
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await page.goto('/')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('每个用例使用独立上下文,初始看板为空', async ({ page }) => {
|
||||||
|
await expect(page.getByText('共 0 个任务')).toBeVisible()
|
||||||
|
await expect(page.locator('article')).toHaveCount(0)
|
||||||
|
await expect(page.getByText('暂无任务')).toHaveCount(3)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('新增 → 拖到进行中 → 标记完成 → 刷新后状态仍正确', async ({ page }) => {
|
||||||
|
await addTask(page, 'E2E 主链路任务')
|
||||||
|
await expect(page.locator('[data-column-id="todo"] article')).toHaveCount(1)
|
||||||
|
|
||||||
|
await dragCardToColumn(page, 'E2E 主链路任务', 'doing')
|
||||||
|
await expect(page.locator('[data-column-id="doing"] article')).toHaveCount(1)
|
||||||
|
await expect(page.locator('[data-column-id="todo"] article')).toHaveCount(0)
|
||||||
|
|
||||||
|
// 刷新后位置保持 —— 这一条是「拖拽结果真的落盘了」的关键证据
|
||||||
|
await page.reload()
|
||||||
|
await expect(
|
||||||
|
page.locator('[data-column-id="doing"] article').filter({ hasText: 'E2E 主链路任务' }),
|
||||||
|
).toBeVisible()
|
||||||
|
|
||||||
|
// 标记完成
|
||||||
|
await page
|
||||||
|
.locator('[data-column-id="doing"] article')
|
||||||
|
.filter({ hasText: 'E2E 主链路任务' })
|
||||||
|
.locator('button')
|
||||||
|
.first()
|
||||||
|
.click()
|
||||||
|
await expect(page.locator('[data-column-id="done"] article')).toHaveCount(1)
|
||||||
|
|
||||||
|
await page.reload()
|
||||||
|
await expect(
|
||||||
|
page.locator('[data-column-id="done"] article').filter({ hasText: 'E2E 主链路任务' }),
|
||||||
|
).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('详情抽屉可编辑并持久化', async ({ page }) => {
|
||||||
|
await addTask(page, '待编辑任务')
|
||||||
|
|
||||||
|
await page.locator('article').filter({ hasText: '待编辑任务' }).click()
|
||||||
|
const titleInput = page.getByLabel('标题')
|
||||||
|
await expect(titleInput).toHaveValue('待编辑任务')
|
||||||
|
|
||||||
|
await titleInput.fill('已编辑任务')
|
||||||
|
await page.getByLabel('描述').fill('这是描述')
|
||||||
|
// 工具栏也有「高」按钮,必须限定在详情抽屉内
|
||||||
|
await page.getByRole('dialog').getByRole('button', { name: '高' }).click()
|
||||||
|
await page.getByRole('button', { name: '保存' }).click()
|
||||||
|
|
||||||
|
await expect(page.locator('article').filter({ hasText: '已编辑任务' })).toBeVisible()
|
||||||
|
|
||||||
|
await page.reload()
|
||||||
|
const card = page.locator('article').filter({ hasText: '已编辑任务' })
|
||||||
|
await expect(card).toBeVisible()
|
||||||
|
await expect(card).toContainText('高')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('删除后可撤销', async ({ page }) => {
|
||||||
|
await addTask(page, '待删除任务')
|
||||||
|
await page.locator('article').filter({ hasText: '待删除任务' }).click()
|
||||||
|
await page.getByRole('button', { name: '删除' }).click()
|
||||||
|
|
||||||
|
await expect(page.locator('article')).toHaveCount(0)
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: '撤销' }).click()
|
||||||
|
await expect(page.locator('article').filter({ hasText: '待删除任务' })).toBeVisible()
|
||||||
|
|
||||||
|
await page.reload()
|
||||||
|
await expect(page.locator('article').filter({ hasText: '待删除任务' })).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('搜索筛选后列计数同步变化', async ({ page }) => {
|
||||||
|
await addTask(page, '写方案')
|
||||||
|
await addTask(page, '买咖啡')
|
||||||
|
|
||||||
|
await expect(page.locator('article')).toHaveCount(2)
|
||||||
|
|
||||||
|
await page.getByLabel('搜索任务').fill('方案')
|
||||||
|
// 关键词有 200ms 防抖
|
||||||
|
await expect(page.locator('article')).toHaveCount(1)
|
||||||
|
await expect(page.locator('article').filter({ hasText: '写方案' })).toBeVisible()
|
||||||
|
await expect(page.locator('section').first().locator('header span')).toHaveText('1')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('键盘快捷键:/ 聚焦搜索,N 聚焦新增,? 打开帮助', async ({ page }) => {
|
||||||
|
await page.keyboard.press('/')
|
||||||
|
await expect(page.getByLabel('搜索任务')).toBeFocused()
|
||||||
|
|
||||||
|
await page.keyboard.press('Escape')
|
||||||
|
await page.locator('body').click({ position: { x: 5, y: 5 } })
|
||||||
|
|
||||||
|
await page.keyboard.press('n')
|
||||||
|
await expect(page.getByLabel('添加任务').first()).toBeFocused()
|
||||||
|
|
||||||
|
await page.locator('body').click({ position: { x: 5, y: 5 } })
|
||||||
|
await page.keyboard.press('?')
|
||||||
|
await expect(page.getByText('聚焦「待办」列的快速新增输入框')).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('输入框内输入不会触发快捷键', async ({ page }) => {
|
||||||
|
const search = page.getByLabel('搜索任务')
|
||||||
|
await search.click()
|
||||||
|
await search.type('n')
|
||||||
|
|
||||||
|
// 焦点应仍在搜索框,而不是跳到新增输入框
|
||||||
|
await expect(search).toBeFocused()
|
||||||
|
await expect(search).toHaveValue('n')
|
||||||
|
})
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import type { Page } from '@playwright/test'
|
||||||
|
|
||||||
|
/** 在「待办」列的快速新增输入框里录入一条任务 */
|
||||||
|
export async function addTask(page: Page, title: string): Promise<void> {
|
||||||
|
const input = page.getByLabel('添加任务').first()
|
||||||
|
await input.fill(title)
|
||||||
|
await input.press('Enter')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把指定标题的卡片拖到目标列。
|
||||||
|
*
|
||||||
|
* SortableJS 用的是 pointer/mouse 事件而不是 HTML5 拖放,
|
||||||
|
* 且依赖拖动过程中的 pointermove 来判定落点,因此必须分步移动鼠标,
|
||||||
|
* 不能只做一次「按下 → 移动 → 抬起」。
|
||||||
|
*/
|
||||||
|
export async function dragCardToColumn(
|
||||||
|
page: Page,
|
||||||
|
title: string,
|
||||||
|
columnId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const card = page.locator('article').filter({ hasText: title }).first()
|
||||||
|
const target = page.locator(`[data-column-id="${columnId}"]`)
|
||||||
|
|
||||||
|
const sourceBox = await card.boundingBox()
|
||||||
|
const targetBox = await target.boundingBox()
|
||||||
|
if (!sourceBox || !targetBox) throw new Error('无法定位拖拽源或目标元素')
|
||||||
|
|
||||||
|
const startX = sourceBox.x + sourceBox.width / 2
|
||||||
|
const startY = sourceBox.y + sourceBox.height / 2
|
||||||
|
|
||||||
|
await page.mouse.move(startX, startY)
|
||||||
|
await page.mouse.down()
|
||||||
|
// 先小幅移动,让 Sortable 进入拖拽状态
|
||||||
|
await page.mouse.move(startX, startY + 12, { steps: 5 })
|
||||||
|
await page.mouse.move(targetBox.x + targetBox.width / 2, targetBox.y + 30, { steps: 20 })
|
||||||
|
await page.mouse.up()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 打开「备份与恢复」面板 */
|
||||||
|
export async function openBackupPanel(page: Page): Promise<void> {
|
||||||
|
await page.getByRole('button', { name: '备份' }).click()
|
||||||
|
}
|
||||||
+122
@@ -0,0 +1,122 @@
|
|||||||
|
import { expect, test } from '@playwright/test'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PWA 端到端验证。
|
||||||
|
*
|
||||||
|
* 这些用例跑在 preview(生产构建)上,因为 Service Worker 只在构建产物中生成。
|
||||||
|
* 每个用例拥有独立的浏览器上下文,因此 Service Worker 也是各自安装的。
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 等待 Service Worker 激活并接管当前页面 */
|
||||||
|
async function waitForServiceWorker(page: import('@playwright/test').Page): Promise<void> {
|
||||||
|
await page.waitForFunction(
|
||||||
|
async () => {
|
||||||
|
const registration = await navigator.serviceWorker.ready
|
||||||
|
return registration.active?.state === 'activated' && navigator.serviceWorker.controller !== null
|
||||||
|
},
|
||||||
|
undefined,
|
||||||
|
{ timeout: 30_000 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
test('manifest 被链接且包含安装所需字段', async ({ page, request }) => {
|
||||||
|
await page.goto('/')
|
||||||
|
|
||||||
|
await expect(page.locator('link[rel="manifest"]')).toHaveAttribute('href', '/manifest.webmanifest')
|
||||||
|
|
||||||
|
const response = await request.get('/manifest.webmanifest')
|
||||||
|
expect(response.status()).toBe(200)
|
||||||
|
|
||||||
|
const manifest = (await response.json()) as {
|
||||||
|
name: string
|
||||||
|
short_name: string
|
||||||
|
start_url: string
|
||||||
|
display: string
|
||||||
|
theme_color: string
|
||||||
|
icons: Array<{ sizes: string; purpose?: string }>
|
||||||
|
shortcuts?: Array<{ url: string }>
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(manifest.name).toBe('看板 · 个人任务管理')
|
||||||
|
expect(manifest.display).toBe('standalone')
|
||||||
|
expect(manifest.start_url).toBe('/')
|
||||||
|
expect(manifest.theme_color).toBe('#0284c7')
|
||||||
|
|
||||||
|
const sizes = manifest.icons.map((icon) => icon.sizes)
|
||||||
|
expect(sizes).toContain('192x192')
|
||||||
|
expect(sizes).toContain('512x512')
|
||||||
|
expect(manifest.icons.some((icon) => icon.purpose === 'maskable')).toBe(true)
|
||||||
|
|
||||||
|
// PWA 快捷方式指向今日视图
|
||||||
|
expect(manifest.shortcuts?.some((shortcut) => shortcut.url === '/today')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('图标资源均可访问', async ({ request }) => {
|
||||||
|
const icons = [
|
||||||
|
'/pwa-192x192.png',
|
||||||
|
'/pwa-512x512.png',
|
||||||
|
'/maskable-512x512.png',
|
||||||
|
'/apple-touch-icon.png',
|
||||||
|
'/favicon.svg',
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const path of icons) {
|
||||||
|
const response = await request.get(path)
|
||||||
|
expect(response.status(), `${path} 应可访问`).toBe(200)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Service Worker 注册并激活', async ({ page }) => {
|
||||||
|
await page.goto('/')
|
||||||
|
await waitForServiceWorker(page)
|
||||||
|
|
||||||
|
const state = await page.evaluate(async () => {
|
||||||
|
const registration = await navigator.serviceWorker.ready
|
||||||
|
return registration.active?.state ?? null
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(state).toBe('activated')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('离线后仍可打开看板,且本地数据完好', async ({ page, context }) => {
|
||||||
|
await page.goto('/')
|
||||||
|
await waitForServiceWorker(page)
|
||||||
|
|
||||||
|
const input = page.getByLabel('添加任务').first()
|
||||||
|
await input.fill('离线也要看到我')
|
||||||
|
await input.press('Enter')
|
||||||
|
await expect(page.locator('article')).toHaveCount(1)
|
||||||
|
|
||||||
|
// 断网后刷新:页面与数据都必须还在
|
||||||
|
await context.setOffline(true)
|
||||||
|
await page.reload()
|
||||||
|
|
||||||
|
await expect(page.getByText('共 1 个任务')).toBeVisible()
|
||||||
|
await expect(page.locator('article').filter({ hasText: '离线也要看到我' })).toBeVisible()
|
||||||
|
|
||||||
|
// 恢复网络,避免影响后续用例
|
||||||
|
await context.setOffline(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('离线时深链 /today 也能打开(navigateFallback 生效)', async ({ page, context }) => {
|
||||||
|
await page.goto('/')
|
||||||
|
await waitForServiceWorker(page)
|
||||||
|
|
||||||
|
await context.setOffline(true)
|
||||||
|
await page.goto('/today')
|
||||||
|
|
||||||
|
await expect(page.getByRole('heading', { name: '今日' })).toBeVisible()
|
||||||
|
await expect(page.getByText('今日待办')).toBeVisible()
|
||||||
|
|
||||||
|
await context.setOffline(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('静态资源被预缓存(sw.js 含 precache 清单)', async ({ request }) => {
|
||||||
|
const response = await request.get('/sw.js')
|
||||||
|
expect(response.status()).toBe(200)
|
||||||
|
|
||||||
|
const source = await response.text()
|
||||||
|
// workbox 生成的是压缩过的清单,这里只确认关键资源确实进了预缓存
|
||||||
|
expect(source).toContain('index.html')
|
||||||
|
expect(source).toContain('manifest.webmanifest')
|
||||||
|
})
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import { expect, test } from '@playwright/test'
|
||||||
|
|
||||||
|
import { addTask, dragCardToColumn } from './helpers'
|
||||||
|
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await page.goto('/')
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 取浏览器本地的今天日期(不能用 toISOString,那是 UTC,可能差一天) */
|
||||||
|
async function localToday(page: import('@playwright/test').Page): Promise<string> {
|
||||||
|
return page.evaluate(() => {
|
||||||
|
const now = new Date()
|
||||||
|
const month = String(now.getMonth() + 1).padStart(2, '0')
|
||||||
|
const day = String(now.getDate()).padStart(2, '0')
|
||||||
|
return `${now.getFullYear()}-${month}-${day}`
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 新建任务并把截止日期设为今天 */
|
||||||
|
async function addTaskDueToday(
|
||||||
|
page: import('@playwright/test').Page,
|
||||||
|
title: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const today = await localToday(page)
|
||||||
|
await addTask(page, title)
|
||||||
|
await page.locator('article').filter({ hasText: title }).click()
|
||||||
|
await page.getByLabel('截止日期').fill(today)
|
||||||
|
await page.getByRole('button', { name: '保存' }).click()
|
||||||
|
await expect(page.locator('article').filter({ hasText: title })).toBeVisible()
|
||||||
|
}
|
||||||
|
|
||||||
|
test('导航可在看板与今日之间切换', async ({ page }) => {
|
||||||
|
await expect(page.getByText('共 0 个任务')).toBeVisible()
|
||||||
|
|
||||||
|
await page.getByRole('link', { name: '今日' }).click()
|
||||||
|
await expect(page.getByRole('heading', { name: '今日' })).toBeVisible()
|
||||||
|
await expect(page.getByText('今日待办')).toBeVisible()
|
||||||
|
|
||||||
|
await page.getByRole('link', { name: '看板' }).click()
|
||||||
|
await expect(page.getByText('共 0 个任务')).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('今日视图汇总今天到期与进行中的任务', async ({ page }) => {
|
||||||
|
await addTaskDueToday(page, '今天到期的事')
|
||||||
|
await addTask(page, '正在做的事')
|
||||||
|
await dragCardToColumn(page, '正在做的事', 'doing')
|
||||||
|
|
||||||
|
await page.getByRole('link', { name: '今日' }).click()
|
||||||
|
|
||||||
|
// 两张卡片都在今日视图里
|
||||||
|
await expect(page.locator('article')).toHaveCount(2)
|
||||||
|
await expect(page.locator('article').filter({ hasText: '今天到期的事' })).toBeVisible()
|
||||||
|
await expect(page.locator('article').filter({ hasText: '正在做的事' })).toBeVisible()
|
||||||
|
|
||||||
|
// 分别落在「今天到期」和「进行中」两组。
|
||||||
|
// 必须限定 level: 2 —— 卡片标题是 h3,任务名恰好叫「今天到期的事」时会撞上分组标题
|
||||||
|
await expect(page.getByRole('heading', { level: 2, name: /今天到期/ })).toBeVisible()
|
||||||
|
await expect(page.getByRole('heading', { level: 2, name: /进行中/ })).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('未开始且无截止日期的任务不进入今日视图', async ({ page }) => {
|
||||||
|
await addTask(page, '以后再说')
|
||||||
|
|
||||||
|
await page.getByRole('link', { name: '今日' }).click()
|
||||||
|
|
||||||
|
await expect(page.locator('article')).toHaveCount(0)
|
||||||
|
await expect(page.getByText('今天没有待办事项')).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('今日视图可以勾选完成', async ({ page }) => {
|
||||||
|
await addTaskDueToday(page, '今天要做完')
|
||||||
|
|
||||||
|
await page.getByRole('link', { name: '今日' }).click()
|
||||||
|
await expect(page.locator('article')).toHaveCount(1)
|
||||||
|
|
||||||
|
await page.locator('article button').first().click()
|
||||||
|
|
||||||
|
await expect(page.locator('article')).toHaveCount(0)
|
||||||
|
await expect(page.getByText('今天没有待办事项')).toBeVisible()
|
||||||
|
|
||||||
|
await page.reload()
|
||||||
|
await expect(page.getByText('今天没有待办事项')).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('今日视图与看板共享同一份数据', async ({ page }) => {
|
||||||
|
await addTask(page, '共享的任务')
|
||||||
|
|
||||||
|
await page.getByRole('link', { name: '今日' }).click()
|
||||||
|
await page.getByRole('link', { name: '看板' }).click()
|
||||||
|
|
||||||
|
await expect(page.locator('article').filter({ hasText: '共享的任务' })).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('未知路径回落到看板', async ({ page }) => {
|
||||||
|
await page.goto('/not-a-real-page')
|
||||||
|
await expect(page.getByText('共 0 个任务')).toBeVisible()
|
||||||
|
})
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import js from '@eslint/js'
|
||||||
|
import prettier from 'eslint-config-prettier/flat'
|
||||||
|
import pluginVue from 'eslint-plugin-vue'
|
||||||
|
import globals from 'globals'
|
||||||
|
import tseslint from 'typescript-eslint'
|
||||||
|
|
||||||
|
export default tseslint.config(
|
||||||
|
{ ignores: ['dist/**', 'node_modules/**', 'coverage/**'] },
|
||||||
|
js.configs.recommended,
|
||||||
|
tseslint.configs.recommended,
|
||||||
|
pluginVue.configs['flat/recommended'],
|
||||||
|
{
|
||||||
|
files: ['**/*.vue'],
|
||||||
|
languageOptions: {
|
||||||
|
parserOptions: { parser: tseslint.parser },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
languageOptions: {
|
||||||
|
globals: { ...globals.browser },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rules: {
|
||||||
|
// 允许用下划线前缀标记「有意不使用」的参数与变量
|
||||||
|
'@typescript-eslint/no-unused-vars': [
|
||||||
|
'error',
|
||||||
|
{
|
||||||
|
argsIgnorePattern: '^_',
|
||||||
|
varsIgnorePattern: '^_',
|
||||||
|
caughtErrorsIgnorePattern: '^_',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
prettier,
|
||||||
|
)
|
||||||
+20
@@ -0,0 +1,20 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="theme-color" content="#0284c7" />
|
||||||
|
<meta name="description" content="轻量的个人看板工具,数据保存在本机浏览器" />
|
||||||
|
<meta name="mobile-web-app-capable" content="yes" />
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||||
|
<meta name="apple-mobile-web-app-title" content="看板" />
|
||||||
|
<title>看板 · 个人任务管理</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Generated
+9913
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,51 @@
|
|||||||
|
{
|
||||||
|
"name": "kanban",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vue-tsc -b && vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"typecheck": "vue-tsc -b",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest",
|
||||||
|
"test:coverage": "vitest run --coverage",
|
||||||
|
"test:e2e": "playwright test",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"lint:fix": "eslint . --fix",
|
||||||
|
"format": "prettier --write .",
|
||||||
|
"format:check": "prettier --check ."
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@vueuse/core": "^15.0.0",
|
||||||
|
"dayjs": "^1.11.23",
|
||||||
|
"pinia": "^4.1.0",
|
||||||
|
"vue": "^3.5.42",
|
||||||
|
"vue-draggable-plus": "^0.6.1",
|
||||||
|
"vue-router": "^5.4.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^10.0.1",
|
||||||
|
"@playwright/test": "^1.64.1",
|
||||||
|
"@tailwindcss/vite": "^4.3.3",
|
||||||
|
"@types/node": "^24.13.3",
|
||||||
|
"@vitejs/plugin-vue": "^6.0.8",
|
||||||
|
"@vitest/coverage-v8": "^5.0.5",
|
||||||
|
"@vue/test-utils": "^2.5.1",
|
||||||
|
"@vue/tsconfig": "^0.9.1",
|
||||||
|
"eslint": "^10.13.0",
|
||||||
|
"eslint-config-prettier": "^10.1.8",
|
||||||
|
"eslint-plugin-vue": "^10.11.1",
|
||||||
|
"globals": "^17.13.0",
|
||||||
|
"jsdom": "^29.1.1",
|
||||||
|
"prettier": "^3.9.10",
|
||||||
|
"tailwindcss": "^4.3.3",
|
||||||
|
"typescript": "~6.0.2",
|
||||||
|
"typescript-eslint": "^8.71.2",
|
||||||
|
"vite": "^8.3.0",
|
||||||
|
"vite-plugin-pwa": "^2.0.0",
|
||||||
|
"vitest": "^5.0.5",
|
||||||
|
"vue-tsc": "^3.3.11"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { defineConfig, devices } from '@playwright/test'
|
||||||
|
|
||||||
|
const PORT = 5199
|
||||||
|
const PREVIEW_PORT = 4173
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 使用系统已安装的浏览器,而不是下载 Playwright 自带的 Chromium。
|
||||||
|
*
|
||||||
|
* 原因:本机对 Playwright CDN 的下载会超时(197MB 拉到 10% 就断),
|
||||||
|
* 而系统已装有 Chrome / Edge,直接驱动即可完成同样的端到端验证。
|
||||||
|
* 若需换回自带浏览器,设置 PLAYWRIGHT_CHANNEL=chromium。
|
||||||
|
*/
|
||||||
|
const CHANNEL = process.env.PLAYWRIGHT_CHANNEL ?? 'chrome'
|
||||||
|
|
||||||
|
const browserUse = {
|
||||||
|
...devices['Desktop Chrome'],
|
||||||
|
channel: CHANNEL === 'chromium' ? undefined : CHANNEL,
|
||||||
|
trace: 'retain-on-failure' as const,
|
||||||
|
}
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: './e2e',
|
||||||
|
fullyParallel: true,
|
||||||
|
forbidOnly: Boolean(process.env.CI),
|
||||||
|
retries: process.env.CI ? 1 : 0,
|
||||||
|
reporter: [['list']],
|
||||||
|
|
||||||
|
projects: [
|
||||||
|
{
|
||||||
|
// 交互行为用 dev server 验证,不必每次都跑生产构建
|
||||||
|
name: `${CHANNEL}-app`,
|
||||||
|
testIgnore: /pwa\.spec\.ts/,
|
||||||
|
use: { ...browserUse, baseURL: `http://localhost:${PORT}` },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Service Worker 只在生产构建里生成,因此 PWA 用例必须跑 preview
|
||||||
|
name: `${CHANNEL}-pwa`,
|
||||||
|
testMatch: /pwa\.spec\.ts/,
|
||||||
|
use: { ...browserUse, baseURL: `http://localhost:${PREVIEW_PORT}` },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
webServer: [
|
||||||
|
{
|
||||||
|
command: `npm run dev -- --port ${PORT} --strictPort`,
|
||||||
|
url: `http://localhost:${PORT}`,
|
||||||
|
reuseExistingServer: !process.env.CI,
|
||||||
|
timeout: 120_000,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
command: `npm run build && npm run preview -- --port ${PREVIEW_PORT} --strictPort`,
|
||||||
|
url: `http://localhost:${PREVIEW_PORT}`,
|
||||||
|
reuseExistingServer: !process.env.CI,
|
||||||
|
timeout: 180_000,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 3.8 KiB |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 9.3 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 9.1 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 4.5 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
@@ -0,0 +1,94 @@
|
|||||||
|
"""生成 PWA 图标。
|
||||||
|
|
||||||
|
设计:天蓝色底 + 三根白色半透明圆角竖条(看板的三列),
|
||||||
|
每根竖条内从上往下堆叠若干不透明白色卡片,数量各不相同。
|
||||||
|
|
||||||
|
注意:Pillow 的 ImageDraw 不做 alpha 混合(半透明填充会直接覆盖像素),
|
||||||
|
因此所有半透明元素必须画在独立图层上,再用 alpha_composite 合成。
|
||||||
|
|
||||||
|
Maskable 版本把内容缩到安全区(中心 80%)内,避免被系统裁成圆形时切掉。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
from PIL import Image, ImageDraw
|
||||||
|
|
||||||
|
OUT_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "public")
|
||||||
|
|
||||||
|
BACKGROUND = (2, 132, 199, 255) # sky-600
|
||||||
|
TRACK = (255, 255, 255, 70) # 半透明的列轨道
|
||||||
|
CARD = (255, 255, 255, 255)
|
||||||
|
|
||||||
|
# 每列的卡片高度(相对内容区高度的比例),从上往下堆叠
|
||||||
|
COLUMNS = [
|
||||||
|
[0.22, 0.15],
|
||||||
|
[0.16, 0.22, 0.13],
|
||||||
|
[0.14],
|
||||||
|
]
|
||||||
|
|
||||||
|
SUPERSAMPLE = 4
|
||||||
|
|
||||||
|
|
||||||
|
def draw_icon(size: int, content_ratio: float) -> Image.Image:
|
||||||
|
"""content_ratio: 内容占据画布的比例(1.0 表示铺满)"""
|
||||||
|
canvas = size * SUPERSAMPLE
|
||||||
|
background = Image.new("RGBA", (canvas, canvas), BACKGROUND)
|
||||||
|
layer = Image.new("RGBA", (canvas, canvas), (0, 0, 0, 0))
|
||||||
|
draw = ImageDraw.Draw(layer)
|
||||||
|
|
||||||
|
content = canvas * content_ratio
|
||||||
|
offset = (canvas - content) / 2
|
||||||
|
|
||||||
|
gap = content * 0.07
|
||||||
|
column_width = (content - gap * (len(COLUMNS) - 1)) / len(COLUMNS)
|
||||||
|
track_radius = max(2, int(column_width * 0.24))
|
||||||
|
|
||||||
|
for index, cards in enumerate(COLUMNS):
|
||||||
|
left = offset + index * (column_width + gap)
|
||||||
|
top = offset
|
||||||
|
bottom = offset + content
|
||||||
|
|
||||||
|
draw.rounded_rectangle(
|
||||||
|
[left, top, left + column_width, bottom],
|
||||||
|
radius=track_radius,
|
||||||
|
fill=TRACK,
|
||||||
|
)
|
||||||
|
|
||||||
|
pad = column_width * 0.17
|
||||||
|
card_width = column_width - pad * 2
|
||||||
|
card_gap = content * 0.035
|
||||||
|
cursor = top + pad
|
||||||
|
|
||||||
|
for ratio in cards:
|
||||||
|
card_height = content * ratio
|
||||||
|
draw.rounded_rectangle(
|
||||||
|
[left + pad, cursor, left + pad + card_width, cursor + card_height],
|
||||||
|
radius=max(2, int(track_radius * 0.5)),
|
||||||
|
fill=CARD,
|
||||||
|
)
|
||||||
|
cursor += card_height + card_gap
|
||||||
|
|
||||||
|
image = Image.alpha_composite(background, layer)
|
||||||
|
return image.resize((size, size), Image.LANCZOS)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
os.makedirs(OUT_DIR, exist_ok=True)
|
||||||
|
|
||||||
|
targets = [
|
||||||
|
("pwa-192x192.png", 192, 0.88),
|
||||||
|
("pwa-512x512.png", 512, 0.88),
|
||||||
|
("maskable-512x512.png", 512, 0.60), # 留出安全区
|
||||||
|
("apple-touch-icon.png", 180, 0.88),
|
||||||
|
]
|
||||||
|
|
||||||
|
for name, size, ratio in targets:
|
||||||
|
path = os.path.join(OUT_DIR, name)
|
||||||
|
draw_icon(size, ratio).convert("RGB").save(path, "PNG", optimize=True)
|
||||||
|
print(f"generated {name} ({size}x{size})")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
+145
@@ -0,0 +1,145 @@
|
|||||||
|
import { mount } from '@vue/test-utils'
|
||||||
|
import { createPinia } from 'pinia'
|
||||||
|
import { nextTick } from 'vue'
|
||||||
|
import { createMemoryHistory } from 'vue-router'
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
import { SCHEMA_VERSION } from '@/data/schema'
|
||||||
|
|
||||||
|
import App from './App.vue'
|
||||||
|
import { createAppRouter } from './router'
|
||||||
|
|
||||||
|
/** PWA 的虚拟模块在测试环境下无法解析,用桩替代 */
|
||||||
|
vi.mock('virtual:pwa-register/vue', async () => {
|
||||||
|
const { ref } = await import('vue')
|
||||||
|
return {
|
||||||
|
useRegisterSW: () => ({
|
||||||
|
needRefresh: ref(false),
|
||||||
|
offlineReady: ref(false),
|
||||||
|
updateServiceWorker: vi.fn(),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 端到端冒烟:走真实的 localStorage 仓库(不注入内存仓库),
|
||||||
|
* 验证「界面操作 → 落盘 → 重新打开仍在」这条最关键的链路。
|
||||||
|
*
|
||||||
|
* 注意:store.init() 在 onMounted 中赋值,Vue 需要一次 tick 才会重渲染,
|
||||||
|
* 因此挂载后必须 await 一次,否则读到的是首帧空看板。
|
||||||
|
*/
|
||||||
|
async function mountApp(initialPath = '/') {
|
||||||
|
const pinia = createPinia()
|
||||||
|
const router = createAppRouter(createMemoryHistory())
|
||||||
|
await router.push(initialPath)
|
||||||
|
|
||||||
|
const wrapper = mount(App, {
|
||||||
|
global: { plugins: [pinia, router] },
|
||||||
|
attachTo: document.body,
|
||||||
|
})
|
||||||
|
await router.isReady()
|
||||||
|
await nextTick()
|
||||||
|
return { wrapper, router }
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('App 端到端(真实 localStorage)', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('界面新增的任务写入 localStorage,重新挂载后仍在', async () => {
|
||||||
|
const { wrapper: first } = await mountApp()
|
||||||
|
|
||||||
|
const input = first.findAll('section')[0]?.find('input')
|
||||||
|
await input?.setValue('持久化验证')
|
||||||
|
await first.findAll('form')[0]?.trigger('submit')
|
||||||
|
|
||||||
|
expect(localStorage.getItem('kanban.tasks')).toContain('持久化验证')
|
||||||
|
first.unmount()
|
||||||
|
|
||||||
|
const { wrapper: second } = await mountApp()
|
||||||
|
expect(second.text()).toContain('持久化验证')
|
||||||
|
expect(second.findAll('article')).toHaveLength(1)
|
||||||
|
second.unmount()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('落盘内容带 schemaVersion', async () => {
|
||||||
|
const { wrapper } = await mountApp()
|
||||||
|
const input = wrapper.findAll('section')[0]?.find('input')
|
||||||
|
await input?.setValue('检查版本字段')
|
||||||
|
await wrapper.findAll('form')[0]?.trigger('submit')
|
||||||
|
|
||||||
|
const raw = localStorage.getItem('kanban.tasks') ?? '{}'
|
||||||
|
expect(JSON.parse(raw).schemaVersion).toBe(SCHEMA_VERSION)
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('点击完成按钮后状态写入 localStorage', async () => {
|
||||||
|
const { wrapper } = await mountApp()
|
||||||
|
const input = wrapper.findAll('section')[0]?.find('input')
|
||||||
|
await input?.setValue('待完成任务')
|
||||||
|
await wrapper.findAll('form')[0]?.trigger('submit')
|
||||||
|
|
||||||
|
await wrapper.find('article button').trigger('click')
|
||||||
|
|
||||||
|
const raw = localStorage.getItem('kanban.tasks') ?? '{}'
|
||||||
|
expect(JSON.parse(raw).tasks[0].status).toBe('done')
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('渲染导航、Toast 容器与 PWA 提示', async () => {
|
||||||
|
const { wrapper } = await mountApp()
|
||||||
|
expect(wrapper.findComponent({ name: 'AppNav' }).exists()).toBe(true)
|
||||||
|
expect(wrapper.findComponent({ name: 'ToastHost' }).exists()).toBe(true)
|
||||||
|
expect(wrapper.findComponent({ name: 'PwaUpdatePrompt' }).exists()).toBe(true)
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('App 路由', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('根路径渲染看板', async () => {
|
||||||
|
const { wrapper } = await mountApp('/')
|
||||||
|
expect(wrapper.text()).toContain('共 0 个任务')
|
||||||
|
expect(wrapper.text()).not.toContain('今日待办')
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('/today 渲染今日视图', async () => {
|
||||||
|
const { wrapper } = await mountApp('/today')
|
||||||
|
expect(wrapper.text()).toContain('今日待办')
|
||||||
|
expect(wrapper.text()).not.toContain('共 0 个任务')
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('未知路径回落到看板', async () => {
|
||||||
|
const { wrapper, router } = await mountApp('/not-exist')
|
||||||
|
await nextTick()
|
||||||
|
expect(router.currentRoute.value.path).toBe('/')
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('两个视图共享同一份数据', async () => {
|
||||||
|
const { wrapper, router } = await mountApp('/')
|
||||||
|
|
||||||
|
const input = wrapper.findAll('section')[0]?.find('input')
|
||||||
|
await input?.setValue('跨视图任务')
|
||||||
|
await wrapper.findAll('form')[0]?.trigger('submit')
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
// 在看板上标记完成(今日视图只统计,不显示已完成的卡片)
|
||||||
|
await wrapper.find('article button').trigger('click')
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
await router.push('/today')
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
const values = wrapper.findAll('dd').map((node) => node.text())
|
||||||
|
// [今日待办, 逾期, 今天到期, 本周完成]
|
||||||
|
expect(values[3]).toBe('1')
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
})
|
||||||
+28
@@ -0,0 +1,28 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted } from 'vue'
|
||||||
|
import { RouterView } from 'vue-router'
|
||||||
|
|
||||||
|
import AppNav from '@/components/AppNav.vue'
|
||||||
|
import PwaUpdatePrompt from '@/components/PwaUpdatePrompt.vue'
|
||||||
|
import ToastHost from '@/components/ToastHost.vue'
|
||||||
|
import { useTaskStore } from '@/stores/tasks'
|
||||||
|
|
||||||
|
const store = useTaskStore()
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
store.init()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex h-full min-h-0 flex-col bg-slate-100">
|
||||||
|
<AppNav />
|
||||||
|
|
||||||
|
<main class="flex min-h-0 flex-1 flex-col">
|
||||||
|
<RouterView />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ToastHost />
|
||||||
|
<PwaUpdatePrompt />
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { flushPromises, mount } from '@vue/test-utils'
|
||||||
|
import { createMemoryHistory, createRouter } from 'vue-router'
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
import { routes } from '@/router'
|
||||||
|
|
||||||
|
import AppNav from './AppNav.vue'
|
||||||
|
|
||||||
|
async function setup(initialPath = '/') {
|
||||||
|
const router = createRouter({ history: createMemoryHistory(), routes })
|
||||||
|
await router.push(initialPath)
|
||||||
|
await router.isReady()
|
||||||
|
|
||||||
|
const wrapper = mount(AppNav, { global: { plugins: [router] }, attachTo: document.body })
|
||||||
|
return { wrapper, router }
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('AppNav', () => {
|
||||||
|
it('渲染看板与今日两个入口', () => {
|
||||||
|
return setup().then(({ wrapper }) => {
|
||||||
|
expect(wrapper.findAll('a').map((node) => node.text())).toEqual(['看板', '今日'])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('当前路由的入口标记 aria-current', async () => {
|
||||||
|
const { wrapper } = await setup('/')
|
||||||
|
const links = wrapper.findAll('a')
|
||||||
|
|
||||||
|
expect(links[0]?.attributes('aria-current')).toBe('page')
|
||||||
|
expect(links[1]?.attributes('aria-current')).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('在今日页时高亮今日入口', async () => {
|
||||||
|
const { wrapper } = await setup('/today')
|
||||||
|
const links = wrapper.findAll('a')
|
||||||
|
|
||||||
|
expect(links[0]?.attributes('aria-current')).toBeUndefined()
|
||||||
|
expect(links[1]?.attributes('aria-current')).toBe('page')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('点击可切换路由', async () => {
|
||||||
|
const { wrapper, router } = await setup('/')
|
||||||
|
|
||||||
|
await wrapper.findAll('a')[1]?.trigger('click')
|
||||||
|
// 路由跳转是异步的,必须等 Promise 落地
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(router.currentRoute.value.path).toBe('/today')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('导航带可访问性标签', async () => {
|
||||||
|
const { wrapper } = await setup()
|
||||||
|
expect(wrapper.find('nav').attributes('aria-label')).toBe('主导航')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { RouterLink } from 'vue-router'
|
||||||
|
|
||||||
|
const LINKS = [
|
||||||
|
{ to: '/', label: '看板' },
|
||||||
|
{ to: '/today', label: '今日' },
|
||||||
|
] as const
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<header
|
||||||
|
class="flex items-center gap-4 border-b border-slate-200 bg-white px-4 py-2.5 md:px-6"
|
||||||
|
>
|
||||||
|
<span class="text-sm font-semibold tracking-wide text-slate-800">看板</span>
|
||||||
|
|
||||||
|
<nav class="flex items-center gap-1" aria-label="主导航">
|
||||||
|
<RouterLink
|
||||||
|
v-for="link in LINKS"
|
||||||
|
:key="link.to"
|
||||||
|
v-slot="{ href, navigate, isExactActive }"
|
||||||
|
:to="link.to"
|
||||||
|
custom
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
:href="href"
|
||||||
|
class="rounded-md px-2.5 py-1 text-sm transition"
|
||||||
|
:class="
|
||||||
|
isExactActive
|
||||||
|
? 'bg-slate-800 font-medium text-white'
|
||||||
|
: 'text-slate-600 hover:bg-slate-100'
|
||||||
|
"
|
||||||
|
:aria-current="isExactActive ? 'page' : undefined"
|
||||||
|
@click="navigate"
|
||||||
|
>
|
||||||
|
{{ link.label }}
|
||||||
|
</a>
|
||||||
|
</RouterLink>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,261 @@
|
|||||||
|
import { flushPromises, mount } from '@vue/test-utils'
|
||||||
|
import type { VueWrapper } from '@vue/test-utils'
|
||||||
|
import { createPinia, setActivePinia } from 'pinia'
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
import { createMemoryTaskRepository } from '@/data/taskRepository'
|
||||||
|
import { useTaskStore } from '@/stores/tasks'
|
||||||
|
|
||||||
|
import BackupPanel from './BackupPanel.vue'
|
||||||
|
|
||||||
|
vi.mock('@/utils/exportFile', async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import('@/utils/exportFile')>()
|
||||||
|
return { ...actual, downloadText: vi.fn() }
|
||||||
|
})
|
||||||
|
|
||||||
|
const { downloadText } = await import('@/utils/exportFile')
|
||||||
|
|
||||||
|
function makePayload(titles: string[]): string {
|
||||||
|
return JSON.stringify({
|
||||||
|
schemaVersion: 1,
|
||||||
|
tasks: titles.map((title, index) => ({
|
||||||
|
id: `imported-${index}`,
|
||||||
|
title,
|
||||||
|
status: 'todo',
|
||||||
|
priority: 2,
|
||||||
|
sortOrder: (index + 1) * 1024,
|
||||||
|
})),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setup(open = true) {
|
||||||
|
const pinia = createPinia()
|
||||||
|
setActivePinia(pinia)
|
||||||
|
const store = useTaskStore()
|
||||||
|
const repository = createMemoryTaskRepository()
|
||||||
|
store.init({ repository, crossTab: false })
|
||||||
|
store.createTask({ title: '原有任务' })
|
||||||
|
|
||||||
|
const wrapper = mount(BackupPanel, {
|
||||||
|
props: { open },
|
||||||
|
global: { plugins: [pinia], stubs: { teleport: true } },
|
||||||
|
attachTo: document.body,
|
||||||
|
})
|
||||||
|
return { store, wrapper }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function chooseFile(wrapper: VueWrapper, content: string, fileName = 'backup.json') {
|
||||||
|
const input = wrapper.find('input[type="file"]')
|
||||||
|
const file = new File([content], fileName, { type: 'application/json' })
|
||||||
|
// jsdom 的 input.files 是只读的,需要直接定义
|
||||||
|
Object.defineProperty(input.element, 'files', { value: [file], configurable: true })
|
||||||
|
await input.trigger('change')
|
||||||
|
await flushPromises()
|
||||||
|
}
|
||||||
|
|
||||||
|
function buttonByText(wrapper: VueWrapper, text: string) {
|
||||||
|
return wrapper.findAll('button').find((node) => node.text().includes(text))
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('BackupPanel 显示', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
setActivePinia(createPinia())
|
||||||
|
vi.mocked(downloadText).mockClear()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('open 为 false 时不渲染内容', () => {
|
||||||
|
const wrapper = mount(BackupPanel, {
|
||||||
|
props: { open: false },
|
||||||
|
global: { stubs: { teleport: true } },
|
||||||
|
})
|
||||||
|
expect(wrapper.text()).not.toContain('导出为 JSON')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('展示数据风险提示', async () => {
|
||||||
|
const { wrapper } = await setup()
|
||||||
|
expect(wrapper.text()).toContain('清理浏览器数据会导致任务丢失')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('BackupPanel 导出', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
setActivePinia(createPinia())
|
||||||
|
vi.mocked(downloadText).mockClear()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('点击导出会生成带日期的文件名并写入内容', async () => {
|
||||||
|
const { store, wrapper } = await setup()
|
||||||
|
await buttonByText(wrapper, '导出为 JSON')?.trigger('click')
|
||||||
|
|
||||||
|
expect(downloadText).toHaveBeenCalledTimes(1)
|
||||||
|
const [content, fileName] = vi.mocked(downloadText).mock.calls[0] ?? []
|
||||||
|
expect(fileName).toMatch(/^kanban-\d{4}-\d{2}-\d{2}\.json$/)
|
||||||
|
expect(content).toContain('原有任务')
|
||||||
|
expect(wrapper.text()).toContain(`已导出 ${store.tasks.length} 个任务`)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('BackupPanel 导入', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
setActivePinia(createPinia())
|
||||||
|
vi.mocked(downloadText).mockClear()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('选择合法文件后先请求确认,不立即改动数据', async () => {
|
||||||
|
const { store, wrapper } = await setup()
|
||||||
|
await chooseFile(wrapper, makePayload(['新任务一', '新任务二']))
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('导入 2 个任务')
|
||||||
|
expect(wrapper.text()).toContain('覆盖当前全部 1 个任务')
|
||||||
|
expect(store.tasks.map((task) => task.title)).toEqual(['原有任务'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('确认后覆盖导入', async () => {
|
||||||
|
const { store, wrapper } = await setup()
|
||||||
|
await chooseFile(wrapper, makePayload(['新任务一', '新任务二']))
|
||||||
|
|
||||||
|
await buttonByText(wrapper, '确认覆盖导入')?.trigger('click')
|
||||||
|
|
||||||
|
expect(store.tasks.map((task) => task.title).sort()).toEqual(['新任务一', '新任务二'])
|
||||||
|
expect(wrapper.text()).toContain('已从 backup.json 导入 2 个任务')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('取消确认后数据保持不变', async () => {
|
||||||
|
const { store, wrapper } = await setup()
|
||||||
|
await chooseFile(wrapper, makePayload(['新任务']))
|
||||||
|
|
||||||
|
await buttonByText(wrapper, '取消')?.trigger('click')
|
||||||
|
|
||||||
|
expect(wrapper.text()).not.toContain('确认覆盖导入')
|
||||||
|
expect(store.tasks.map((task) => task.title)).toEqual(['原有任务'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('非法 JSON 给出明确错误且不破坏现有数据', async () => {
|
||||||
|
const { store, wrapper } = await setup()
|
||||||
|
await chooseFile(wrapper, '{broken json')
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('不是合法的 JSON')
|
||||||
|
expect(store.tasks.map((task) => task.title)).toEqual(['原有任务'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('结构不合法时给出错误且不进入确认流程', async () => {
|
||||||
|
const { store, wrapper } = await setup()
|
||||||
|
await chooseFile(wrapper, JSON.stringify({ tasks: [{ id: 'broken' }] }))
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('第 1 条任务格式不合法')
|
||||||
|
expect(wrapper.text()).not.toContain('确认覆盖导入')
|
||||||
|
expect(store.tasks.map((task) => task.title)).toEqual(['原有任务'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('版本过高时拒绝导入', async () => {
|
||||||
|
const { store, wrapper } = await setup()
|
||||||
|
await chooseFile(wrapper, JSON.stringify({ schemaVersion: 99, tasks: [] }))
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('高于当前支持')
|
||||||
|
expect(store.tasks.map((task) => task.title)).toEqual(['原有任务'])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('BackupPanel 自动备份', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
setActivePinia(createPinia())
|
||||||
|
vi.mocked(downloadText).mockClear()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('启动时写入的快照会出现在列表中', async () => {
|
||||||
|
const repository = createMemoryTaskRepository()
|
||||||
|
|
||||||
|
// 第一次启动:看板为空,不产生快照
|
||||||
|
setActivePinia(createPinia())
|
||||||
|
const first = useTaskStore()
|
||||||
|
first.init({ repository, crossTab: false })
|
||||||
|
first.createTask({ title: '已有任务' })
|
||||||
|
|
||||||
|
// 第二次启动:此时已有数据,写入启动快照
|
||||||
|
const pinia = createPinia()
|
||||||
|
setActivePinia(pinia)
|
||||||
|
const store = useTaskStore()
|
||||||
|
store.init({ repository, crossTab: false })
|
||||||
|
expect(store.snapshots).toHaveLength(1)
|
||||||
|
|
||||||
|
const wrapper = mount(BackupPanel, {
|
||||||
|
props: { open: true },
|
||||||
|
global: { plugins: [pinia], stubs: { teleport: true } },
|
||||||
|
})
|
||||||
|
expect(wrapper.text()).not.toContain('暂无自动备份')
|
||||||
|
expect(buttonByText(wrapper, '恢复')).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('空看板不写快照,显示暂无备份', async () => {
|
||||||
|
const pinia = createPinia()
|
||||||
|
setActivePinia(pinia)
|
||||||
|
const store = useTaskStore()
|
||||||
|
store.init({ repository: createMemoryTaskRepository(), crossTab: false })
|
||||||
|
|
||||||
|
const wrapper = mount(BackupPanel, {
|
||||||
|
props: { open: true },
|
||||||
|
global: { plugins: [pinia], stubs: { teleport: true } },
|
||||||
|
})
|
||||||
|
expect(wrapper.text()).toContain('暂无自动备份')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('看板为空时仍会列出已有快照(数据丢失后要能恢复)', async () => {
|
||||||
|
const repository = createMemoryTaskRepository()
|
||||||
|
|
||||||
|
setActivePinia(createPinia())
|
||||||
|
const first = useTaskStore()
|
||||||
|
first.init({ repository, crossTab: false })
|
||||||
|
first.createTask({ title: '快照里的任务' })
|
||||||
|
|
||||||
|
// 第二次启动:写入快照
|
||||||
|
setActivePinia(createPinia())
|
||||||
|
const second = useTaskStore()
|
||||||
|
second.init({ repository, crossTab: false })
|
||||||
|
expect(second.snapshots).toHaveLength(1)
|
||||||
|
|
||||||
|
// 清空数据后第三次启动:不能因为看板为空就丢掉快照列表
|
||||||
|
second.replaceAll([])
|
||||||
|
const pinia = createPinia()
|
||||||
|
setActivePinia(pinia)
|
||||||
|
const third = useTaskStore()
|
||||||
|
third.init({ repository, crossTab: false })
|
||||||
|
|
||||||
|
expect(third.tasks).toHaveLength(0)
|
||||||
|
expect(third.snapshots).toHaveLength(1)
|
||||||
|
|
||||||
|
const wrapper = mount(BackupPanel, {
|
||||||
|
props: { open: true },
|
||||||
|
global: { plugins: [pinia], stubs: { teleport: true } },
|
||||||
|
})
|
||||||
|
expect(wrapper.text()).not.toContain('暂无自动备份')
|
||||||
|
expect(buttonByText(wrapper, '恢复')).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('点击恢复会用快照内容覆盖当前数据', async () => {
|
||||||
|
const repository = createMemoryTaskRepository()
|
||||||
|
|
||||||
|
setActivePinia(createPinia())
|
||||||
|
const first = useTaskStore()
|
||||||
|
first.init({ repository, crossTab: false })
|
||||||
|
first.createTask({ title: '快照里的任务' })
|
||||||
|
|
||||||
|
const pinia = createPinia()
|
||||||
|
setActivePinia(pinia)
|
||||||
|
const store = useTaskStore()
|
||||||
|
store.init({ repository, crossTab: false })
|
||||||
|
expect(store.snapshots).toHaveLength(1)
|
||||||
|
|
||||||
|
// 模拟数据丢失
|
||||||
|
store.replaceAll([])
|
||||||
|
expect(store.tasks).toHaveLength(0)
|
||||||
|
|
||||||
|
const wrapper = mount(BackupPanel, {
|
||||||
|
props: { open: true },
|
||||||
|
global: { plugins: [pinia], stubs: { teleport: true } },
|
||||||
|
})
|
||||||
|
await buttonByText(wrapper, '恢复')?.trigger('click')
|
||||||
|
|
||||||
|
expect(store.tasks.map((task) => task.title)).toEqual(['快照里的任务'])
|
||||||
|
expect(wrapper.text()).toContain('已从备份恢复 1 个任务')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import dayjs from 'dayjs'
|
||||||
|
import { storeToRefs } from 'pinia'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
import { parseImportJson } from '@/data/schema'
|
||||||
|
import { useTaskStore } from '@/stores/tasks'
|
||||||
|
import { downloadText, exportFileName } from '@/utils/exportFile'
|
||||||
|
|
||||||
|
import SideDrawer from './SideDrawer.vue'
|
||||||
|
|
||||||
|
const props = defineProps<{ open: boolean }>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{ close: [] }>()
|
||||||
|
|
||||||
|
const store = useTaskStore()
|
||||||
|
const { snapshots } = storeToRefs(store)
|
||||||
|
|
||||||
|
const message = ref<string | null>(null)
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
|
||||||
|
/** 已选好文件但还没确认的导入(覆盖式操作需要二次确认) */
|
||||||
|
const pending = ref<{ text: string; fileName: string; count: number } | null>(null)
|
||||||
|
|
||||||
|
function exportNow(): void {
|
||||||
|
const fileName = exportFileName()
|
||||||
|
try {
|
||||||
|
downloadText(store.exportJson(), fileName)
|
||||||
|
message.value = `已导出 ${store.tasks.length} 个任务到 ${fileName}`
|
||||||
|
error.value = null
|
||||||
|
} catch (cause) {
|
||||||
|
// 浏览器可能因权限或存储限制拒绝下载,这里必须让用户看到失败原因
|
||||||
|
error.value = cause instanceof Error ? cause.message : '导出失败'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onFileChange(event: Event): Promise<void> {
|
||||||
|
const input = event.target as HTMLInputElement
|
||||||
|
const file = input.files?.[0]
|
||||||
|
// 清空以便连续选择同一个文件时也能触发 change
|
||||||
|
input.value = ''
|
||||||
|
if (!file) return
|
||||||
|
|
||||||
|
message.value = null
|
||||||
|
error.value = null
|
||||||
|
|
||||||
|
try {
|
||||||
|
const text = await file.text()
|
||||||
|
// 先做一次纯校验,确认合法后才让用户确认覆盖
|
||||||
|
const tasks = parseImportJson(text)
|
||||||
|
pending.value = { text, fileName: file.name, count: tasks.length }
|
||||||
|
} catch (cause) {
|
||||||
|
pending.value = null
|
||||||
|
error.value = cause instanceof Error ? cause.message : '导入文件无法解析'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmImport(): void {
|
||||||
|
const target = pending.value
|
||||||
|
if (!target) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const count = store.importJson(target.text)
|
||||||
|
message.value = `已从 ${target.fileName} 导入 ${count} 个任务`
|
||||||
|
error.value = null
|
||||||
|
} catch (cause) {
|
||||||
|
error.value = cause instanceof Error ? cause.message : '导入失败'
|
||||||
|
} finally {
|
||||||
|
pending.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function restore(key: string): void {
|
||||||
|
try {
|
||||||
|
const count = store.restoreSnapshot(key)
|
||||||
|
message.value = count > 0 ? `已从备份恢复 ${count} 个任务` : '该备份是空的,未做改动'
|
||||||
|
error.value = null
|
||||||
|
} catch (cause) {
|
||||||
|
error.value = cause instanceof Error ? cause.message : '恢复失败'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(iso: string): string {
|
||||||
|
return dayjs(iso).format('YYYY-MM-DD HH:mm:ss')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<SideDrawer :open="props.open" title="备份与恢复" @close="emit('close')">
|
||||||
|
<div class="flex flex-col gap-6">
|
||||||
|
<p class="rounded-md bg-amber-50 px-3 py-2 text-xs leading-relaxed text-amber-800">
|
||||||
|
数据只保存在这台设备的浏览器里。清理浏览器数据会导致任务丢失,建议定期导出备份。
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p
|
||||||
|
v-if="message"
|
||||||
|
role="status"
|
||||||
|
class="rounded-md border border-emerald-300 bg-emerald-50 px-3 py-2 text-sm text-emerald-800"
|
||||||
|
>
|
||||||
|
{{ message }}
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
v-if="error"
|
||||||
|
role="alert"
|
||||||
|
class="rounded-md border border-rose-300 bg-rose-50 px-3 py-2 text-sm text-rose-700"
|
||||||
|
>
|
||||||
|
{{ error }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<section class="flex flex-col gap-2">
|
||||||
|
<h3 class="text-sm font-medium text-slate-700">导出</h3>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="self-start rounded-md bg-sky-600 px-3 py-1.5 text-sm font-medium text-white transition hover:bg-sky-700"
|
||||||
|
@click="exportNow"
|
||||||
|
>
|
||||||
|
导出为 JSON
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="flex flex-col gap-2">
|
||||||
|
<h3 class="text-sm font-medium text-slate-700">导入</h3>
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="application/json,.json"
|
||||||
|
class="block w-full text-sm text-slate-600 file:mr-3 file:rounded-md file:border file:border-slate-300 file:bg-white file:px-3 file:py-1.5 file:text-sm file:text-slate-600"
|
||||||
|
aria-label="选择导入文件"
|
||||||
|
@change="onFileChange"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="pending"
|
||||||
|
class="flex flex-col gap-2 rounded-md border border-amber-300 bg-amber-50 px-3 py-2"
|
||||||
|
>
|
||||||
|
<p class="text-sm text-amber-900">
|
||||||
|
将从「{{ pending.fileName }}」导入 {{ pending.count }} 个任务,
|
||||||
|
<strong>并覆盖当前全部 {{ store.tasks.length }} 个任务</strong>。此操作不可撤销。
|
||||||
|
</p>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-md bg-rose-600 px-3 py-1 text-sm font-medium text-white transition hover:bg-rose-700"
|
||||||
|
@click="confirmImport"
|
||||||
|
>
|
||||||
|
确认覆盖导入
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-md border border-slate-300 px-3 py-1 text-sm text-slate-600 transition hover:bg-white"
|
||||||
|
@click="pending = null"
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="flex flex-col gap-2">
|
||||||
|
<h3 class="text-sm font-medium text-slate-700">自动备份</h3>
|
||||||
|
<p class="text-xs text-slate-500">每次打开看板时自动留一份,最多保留 3 份。</p>
|
||||||
|
|
||||||
|
<ul v-if="snapshots.length > 0" class="flex flex-col gap-2">
|
||||||
|
<li
|
||||||
|
v-for="item in snapshots"
|
||||||
|
:key="item.key"
|
||||||
|
class="flex items-center justify-between rounded-md border border-slate-200 px-3 py-2"
|
||||||
|
>
|
||||||
|
<span class="text-sm text-slate-600">{{ formatTime(item.savedAt) }}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-md px-2 py-0.5 text-sm text-sky-700 transition hover:bg-sky-50"
|
||||||
|
@click="restore(item.key)"
|
||||||
|
>
|
||||||
|
恢复
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<p v-else class="text-sm text-slate-400">暂无自动备份</p>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</SideDrawer>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
import { mount } from '@vue/test-utils'
|
||||||
|
import { createPinia, setActivePinia } from 'pinia'
|
||||||
|
import { useDraggable } from 'vue-draggable-plus'
|
||||||
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
import { createMemoryTaskRepository } from '@/data/taskRepository'
|
||||||
|
import { useTaskStore } from '@/stores/tasks'
|
||||||
|
import type { ColumnDef, Task } from '@/types/task'
|
||||||
|
import { resolveDrop } from '@/utils/drag'
|
||||||
|
|
||||||
|
import BoardColumn from './BoardColumn.vue'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 屏蔽真实的 Sortable:jsdom 没有布局,真实实例在测试中不起作用,
|
||||||
|
* 而且我们要断言的是「拖拽事件 → emit」这段接线,直接调用库回调更可靠。
|
||||||
|
*/
|
||||||
|
vi.mock('vue-draggable-plus', () => ({ useDraggable: vi.fn() }))
|
||||||
|
|
||||||
|
interface DragOptions {
|
||||||
|
onEnd?: (event: unknown) => void
|
||||||
|
onMove?: (event: unknown, originalEvent: unknown) => unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 取最近一次传给 useDraggable 的配置 */
|
||||||
|
function lastDragOptions(): DragOptions {
|
||||||
|
const calls = vi.mocked(useDraggable).mock.calls
|
||||||
|
return (calls[calls.length - 1]?.[1] ?? {}) as DragOptions
|
||||||
|
}
|
||||||
|
|
||||||
|
const TODO_COLUMN: ColumnDef = { id: 'todo', title: '待办' }
|
||||||
|
|
||||||
|
function makeTask(overrides: Partial<Task> = {}): Task {
|
||||||
|
return {
|
||||||
|
id: 'task-1',
|
||||||
|
title: '写方案',
|
||||||
|
description: '',
|
||||||
|
status: 'todo',
|
||||||
|
priority: 2,
|
||||||
|
tags: [],
|
||||||
|
dueDate: null,
|
||||||
|
sortOrder: 1024,
|
||||||
|
createdAt: '2026-10-18T00:00:00.000Z',
|
||||||
|
updatedAt: '2026-10-18T00:00:00.000Z',
|
||||||
|
completedAt: null,
|
||||||
|
archivedAt: null,
|
||||||
|
...overrides,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setup(tasks: Task[] = [makeTask()], sortDisabled = false) {
|
||||||
|
const pinia = createPinia()
|
||||||
|
setActivePinia(pinia)
|
||||||
|
const store = useTaskStore()
|
||||||
|
store.init({ repository: createMemoryTaskRepository(tasks), crossTab: false })
|
||||||
|
|
||||||
|
const wrapper = mount(BoardColumn, {
|
||||||
|
props: { column: TODO_COLUMN, tasks: store.byColumn.todo, sortDisabled },
|
||||||
|
global: { plugins: [pinia] },
|
||||||
|
})
|
||||||
|
return { store, wrapper }
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('BoardColumn 渲染', () => {
|
||||||
|
it('渲染列标题与计数', () => {
|
||||||
|
const { wrapper } = setup([makeTask({ id: 'a' }), makeTask({ id: 'b' })])
|
||||||
|
expect(wrapper.find('h2').text()).toBe('待办')
|
||||||
|
expect(wrapper.find('header span').text()).toBe('2')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('空列显示空状态', () => {
|
||||||
|
const { wrapper } = setup([])
|
||||||
|
expect(wrapper.findAll('article')).toHaveLength(0)
|
||||||
|
expect(wrapper.text()).toContain('暂无任务')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('列内包含快速新增输入框', () => {
|
||||||
|
const { wrapper } = setup([])
|
||||||
|
expect(wrapper.find('input[aria-label="添加任务"]').exists()).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('BoardColumn 拖拽契约', () => {
|
||||||
|
it('拖拽容器带 data-column-id', () => {
|
||||||
|
const { wrapper } = setup()
|
||||||
|
expect(wrapper.find('[data-column-id="todo"]').exists()).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('卡片带 data-task-id', () => {
|
||||||
|
const { wrapper } = setup([makeTask({ id: 'task-xyz' })])
|
||||||
|
expect(wrapper.find('[data-task-id="task-xyz"]').exists()).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('渲染出的真实 DOM 能满足 resolveDrop 的解析契约', () => {
|
||||||
|
const { wrapper } = setup([makeTask({ id: 'task-1' }), makeTask({ id: 'task-2' })])
|
||||||
|
|
||||||
|
const list = wrapper.find('[data-column-id="todo"]').element as HTMLElement
|
||||||
|
const item = wrapper.find('[data-task-id="task-2"]').element as HTMLElement
|
||||||
|
|
||||||
|
expect(resolveDrop({ item, to: list, newIndex: 0 })).toEqual({
|
||||||
|
taskId: 'task-2',
|
||||||
|
toColumn: 'todo',
|
||||||
|
toIndex: 0,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('BoardColumn 交互', () => {
|
||||||
|
it('点击完成按钮 emit toggle', async () => {
|
||||||
|
const { wrapper } = setup([makeTask({ id: 'task-1' })])
|
||||||
|
await wrapper.find('article button').trigger('click')
|
||||||
|
expect(wrapper.emitted('toggle')).toEqual([['task-1']])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('点击卡片 emit open', async () => {
|
||||||
|
const { wrapper } = setup([makeTask({ id: 'task-1' })])
|
||||||
|
await wrapper.find('article').trigger('click')
|
||||||
|
expect(wrapper.emitted('open')).toEqual([['task-1']])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('通过界面新增任务会写入 store', async () => {
|
||||||
|
const { store, wrapper } = setup([])
|
||||||
|
await wrapper.find('input[aria-label="添加任务"]').setValue('新任务')
|
||||||
|
await wrapper.find('form').trigger('submit')
|
||||||
|
expect(store.byColumn.todo.map((task) => task.title)).toEqual(['新任务'])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('BoardColumn 长列表', () => {
|
||||||
|
function makeMany(count: number): Task[] {
|
||||||
|
return Array.from({ length: count }, (_, index) =>
|
||||||
|
makeTask({ id: `task-${index}`, title: `任务 ${index}`, sortOrder: (index + 1) * 1024 }),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
it('单列 200 张卡片全部渲染且计数正确', () => {
|
||||||
|
const { wrapper } = setup(makeMany(200))
|
||||||
|
|
||||||
|
expect(wrapper.findAll('article')).toHaveLength(200)
|
||||||
|
expect(wrapper.find('header span').text()).toBe('200')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('200 张卡片仍保持 sortOrder 升序渲染', () => {
|
||||||
|
const { wrapper } = setup(makeMany(200))
|
||||||
|
const titles = wrapper.findAll('article h3').map((node) => node.text())
|
||||||
|
|
||||||
|
expect(titles[0]).toBe('任务 0')
|
||||||
|
expect(titles[199]).toBe('任务 199')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('200 张卡片时仍能通过界面新增任务', async () => {
|
||||||
|
const { store, wrapper } = setup(makeMany(200))
|
||||||
|
|
||||||
|
await wrapper.find('input[aria-label="添加任务"]').setValue('插到最前')
|
||||||
|
await wrapper.find('form').trigger('submit')
|
||||||
|
|
||||||
|
expect(store.byColumn.todo).toHaveLength(201)
|
||||||
|
expect(store.byColumn.todo[0]?.title).toBe('插到最前')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('BoardColumn 拖拽事件接线', () => {
|
||||||
|
it('onEnd 解析落点并 emit move', () => {
|
||||||
|
const { wrapper } = setup([makeTask({ id: 'task-1' })])
|
||||||
|
const item = wrapper.find('[data-task-id="task-1"]').element
|
||||||
|
const to = wrapper.find('[data-column-id="todo"]').element
|
||||||
|
|
||||||
|
lastDragOptions().onEnd?.({ item, to, newIndex: 0 })
|
||||||
|
|
||||||
|
expect(wrapper.emitted('move')).toEqual([[{ id: 'task-1', toColumn: 'todo', toIndex: 0 }]])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('onEnd 缺少任务信息时不 emit', () => {
|
||||||
|
const { wrapper } = setup()
|
||||||
|
const to = wrapper.find('[data-column-id="todo"]').element
|
||||||
|
|
||||||
|
lastDragOptions().onEnd?.({ item: document.createElement('article'), to, newIndex: 0 })
|
||||||
|
|
||||||
|
expect(wrapper.emitted('move')).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('onEnd 下标缺失时不 emit', () => {
|
||||||
|
const { wrapper } = setup([makeTask({ id: 'task-1' })])
|
||||||
|
const item = wrapper.find('[data-task-id="task-1"]').element
|
||||||
|
const to = wrapper.find('[data-column-id="todo"]').element
|
||||||
|
|
||||||
|
lastDragOptions().onEnd?.({ item, to })
|
||||||
|
|
||||||
|
expect(wrapper.emitted('move')).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('列内为全量渲染时 onMove 一律允许', () => {
|
||||||
|
const { wrapper } = setup([makeTask()], false)
|
||||||
|
const same = wrapper.find('[data-column-id="todo"]').element
|
||||||
|
|
||||||
|
const result = lastDragOptions().onMove?.({ from: same, to: same }, new Event('move'))
|
||||||
|
expect(result).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sortDisabled 时 onMove 禁止列内排序但允许跨列', () => {
|
||||||
|
const { wrapper } = setup([makeTask()], true)
|
||||||
|
const same = wrapper.find('[data-column-id="todo"]').element
|
||||||
|
const other = document.createElement('div')
|
||||||
|
|
||||||
|
const onMove = lastDragOptions().onMove
|
||||||
|
expect(onMove?.({ from: same, to: same }, new Event('move'))).toBe(false)
|
||||||
|
expect(onMove?.({ from: same, to: other }, new Event('move'))).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('传给 useDraggable 的分组与动画配置正确', () => {
|
||||||
|
setup()
|
||||||
|
const calls = vi.mocked(useDraggable).mock.calls
|
||||||
|
const options = calls[calls.length - 1]?.[1] as Record<string, unknown>
|
||||||
|
|
||||||
|
expect(options.group).toBe('kanban-board')
|
||||||
|
expect(options.filter).toBe('button')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { useDraggable } from 'vue-draggable-plus'
|
||||||
|
|
||||||
|
import type { ColumnDef, ColumnId, Task } from '@/types/task'
|
||||||
|
import { resolveDrop, shouldAllowMove } from '@/utils/drag'
|
||||||
|
|
||||||
|
import QuickAddTask from './QuickAddTask.vue'
|
||||||
|
import TaskCard from './TaskCard.vue'
|
||||||
|
|
||||||
|
const props = defineProps<{ column: ColumnDef; tasks: Task[]; sortDisabled?: boolean }>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
toggle: [id: string]
|
||||||
|
open: [id: string]
|
||||||
|
move: [payload: { id: string; toColumn: ColumnId; toIndex: number }]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const listRef = ref<HTMLElement | null>(null)
|
||||||
|
const quickAddRef = ref<InstanceType<typeof QuickAddTask> | null>(null)
|
||||||
|
|
||||||
|
/** 供快捷键(N)调用,聚焦到该列的快速新增输入框 */
|
||||||
|
function focusQuickAdd(): void {
|
||||||
|
quickAddRef.value?.focusInput()
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({ focusQuickAdd })
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 不传 list:库不会接管数据,也不会安装它自己的 DOM 修正逻辑,
|
||||||
|
* 我们只消费事件并把结果交给 store,保持「store 是唯一数据源」。
|
||||||
|
*/
|
||||||
|
useDraggable(listRef, {
|
||||||
|
group: 'kanban-board',
|
||||||
|
animation: 150,
|
||||||
|
ghostClass: 'opacity-40',
|
||||||
|
// 在完成勾选按钮上按下时不启动拖拽
|
||||||
|
filter: 'button',
|
||||||
|
onMove: (event) => shouldAllowMove(event.from, event.to, props.sortDisabled === true),
|
||||||
|
onEnd: (event) => {
|
||||||
|
const drop = resolveDrop(event)
|
||||||
|
if (!drop) return
|
||||||
|
emit('move', { id: drop.taskId, toColumn: drop.toColumn, toIndex: drop.toIndex })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="flex min-h-0 flex-col rounded-lg bg-slate-200/60">
|
||||||
|
<header class="flex items-center justify-between px-3 py-2.5">
|
||||||
|
<h2 class="text-sm font-medium text-slate-600">{{ column.title }}</h2>
|
||||||
|
<span class="rounded-full bg-white px-2 py-0.5 text-xs font-medium text-slate-500">
|
||||||
|
{{ tasks.length }}
|
||||||
|
</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<QuickAddTask ref="quickAddRef" :status="column.id" />
|
||||||
|
|
||||||
|
<div
|
||||||
|
ref="listRef"
|
||||||
|
:data-column-id="column.id"
|
||||||
|
class="flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto px-2 pb-2"
|
||||||
|
>
|
||||||
|
<TaskCard
|
||||||
|
v-for="task in tasks"
|
||||||
|
:key="task.id"
|
||||||
|
:task="task"
|
||||||
|
@toggle="emit('toggle', $event)"
|
||||||
|
@open="emit('open', $event)"
|
||||||
|
/>
|
||||||
|
<p v-if="tasks.length === 0" class="px-2 py-6 text-center text-sm text-slate-400">
|
||||||
|
暂无任务
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
import { mount } from '@vue/test-utils'
|
||||||
|
import { createPinia, setActivePinia } from 'pinia'
|
||||||
|
import dayjs from 'dayjs'
|
||||||
|
import { beforeEach, describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
import { createMemoryTaskRepository } from '@/data/taskRepository'
|
||||||
|
import { useFilterStore } from '@/stores/filters'
|
||||||
|
import { useTaskStore } from '@/stores/tasks'
|
||||||
|
|
||||||
|
import BoardToolbar from './BoardToolbar.vue'
|
||||||
|
|
||||||
|
function setup() {
|
||||||
|
const pinia = createPinia()
|
||||||
|
setActivePinia(pinia)
|
||||||
|
const store = useTaskStore()
|
||||||
|
store.init({ repository: createMemoryTaskRepository(), crossTab: false })
|
||||||
|
const filters = useFilterStore()
|
||||||
|
|
||||||
|
const wrapper = mount(BoardToolbar, {
|
||||||
|
global: { plugins: [pinia] },
|
||||||
|
attachTo: document.body,
|
||||||
|
})
|
||||||
|
return { store, filters, wrapper }
|
||||||
|
}
|
||||||
|
|
||||||
|
const today = dayjs().format('YYYY-MM-DD')
|
||||||
|
const yesterday = dayjs().subtract(1, 'day').format('YYYY-MM-DD')
|
||||||
|
|
||||||
|
function buttonByText(wrapper: ReturnType<typeof mount>, text: string) {
|
||||||
|
return wrapper.findAll('button').find((node) => node.text().includes(text))
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('BoardToolbar 统计', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
setActivePinia(createPinia())
|
||||||
|
})
|
||||||
|
|
||||||
|
it('显示任务总数', async () => {
|
||||||
|
const { store, wrapper } = setup()
|
||||||
|
store.createTask({ title: 'A' })
|
||||||
|
store.createTask({ title: 'B' })
|
||||||
|
await wrapper.vm.$nextTick()
|
||||||
|
expect(wrapper.text()).toContain('共 2 个任务')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('统计今日到期与逾期数量', async () => {
|
||||||
|
const { store, wrapper } = setup()
|
||||||
|
store.createTask({ title: '今天到期', dueDate: today })
|
||||||
|
store.createTask({ title: '逾期一', dueDate: yesterday })
|
||||||
|
store.createTask({ title: '逾期二', dueDate: yesterday })
|
||||||
|
await wrapper.vm.$nextTick()
|
||||||
|
|
||||||
|
expect(buttonByText(wrapper, '今日到期')?.text()).toContain('今日到期 1')
|
||||||
|
expect(buttonByText(wrapper, '逾期')?.text()).toContain('逾期 2')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('已完成任务不计入到期统计', async () => {
|
||||||
|
const { store, wrapper } = setup()
|
||||||
|
store.createTask({ title: '已完成', dueDate: yesterday, status: 'done' })
|
||||||
|
await wrapper.vm.$nextTick()
|
||||||
|
expect(buttonByText(wrapper, '逾期')?.text()).toContain('逾期 0')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('BoardToolbar 筛选交互', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
setActivePinia(createPinia())
|
||||||
|
})
|
||||||
|
|
||||||
|
it('点击「逾期」切换截止日期筛选', async () => {
|
||||||
|
const { filters, wrapper } = setup()
|
||||||
|
await buttonByText(wrapper, '逾期')?.trigger('click')
|
||||||
|
expect(filters.due).toBe('overdue')
|
||||||
|
|
||||||
|
await buttonByText(wrapper, '逾期')?.trigger('click')
|
||||||
|
expect(filters.due).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('点击「今日到期」切换截止日期筛选', async () => {
|
||||||
|
const { filters, wrapper } = setup()
|
||||||
|
await buttonByText(wrapper, '今日到期')?.trigger('click')
|
||||||
|
expect(filters.due).toBe('today')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('优先级按钮切换筛选条件', async () => {
|
||||||
|
const { filters, wrapper } = setup()
|
||||||
|
await buttonByText(wrapper, '高')?.trigger('click')
|
||||||
|
expect(filters.priorities).toEqual([3])
|
||||||
|
|
||||||
|
await buttonByText(wrapper, '高')?.trigger('click')
|
||||||
|
expect(filters.priorities).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('标签候选来自现有任务,点击可筛选', async () => {
|
||||||
|
const { store, filters, wrapper } = setup()
|
||||||
|
store.createTask({ title: 'A', tags: ['设计'] })
|
||||||
|
await wrapper.vm.$nextTick()
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('#设计')
|
||||||
|
|
||||||
|
await buttonByText(wrapper, '#设计')?.trigger('click')
|
||||||
|
expect(filters.tags).toEqual(['设计'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('搜索框写入关键词', async () => {
|
||||||
|
const { filters, wrapper } = setup()
|
||||||
|
await wrapper.find('input[aria-label="搜索任务"]').setValue('方案')
|
||||||
|
expect(filters.keyword).toBe('方案')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('无筛选时不显示「清空筛选」', () => {
|
||||||
|
const { wrapper } = setup()
|
||||||
|
expect(buttonByText(wrapper, '清空筛选')).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('有筛选时显示「清空筛选」并可用', async () => {
|
||||||
|
const { filters, wrapper } = setup()
|
||||||
|
filters.togglePriority(3)
|
||||||
|
await wrapper.vm.$nextTick()
|
||||||
|
|
||||||
|
await buttonByText(wrapper, '清空筛选')?.trigger('click')
|
||||||
|
expect(filters.active).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('BoardToolbar 面板入口', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
setActivePinia(createPinia())
|
||||||
|
})
|
||||||
|
|
||||||
|
it('点击「备份」发出 open-backup', async () => {
|
||||||
|
const { wrapper } = setup()
|
||||||
|
await buttonByText(wrapper, '备份')?.trigger('click')
|
||||||
|
expect(wrapper.emitted('open-backup')).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('点击 ? 发出 open-help', async () => {
|
||||||
|
const { wrapper } = setup()
|
||||||
|
await wrapper.find('button[aria-label="快捷键帮助"]').trigger('click')
|
||||||
|
expect(wrapper.emitted('open-help')).toHaveLength(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('BoardToolbar 焦点', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
setActivePinia(createPinia())
|
||||||
|
})
|
||||||
|
|
||||||
|
it('focusSearch 聚焦搜索框', async () => {
|
||||||
|
const { wrapper } = setup()
|
||||||
|
const search = wrapper.find('input[aria-label="搜索任务"]')
|
||||||
|
|
||||||
|
;(wrapper.vm as unknown as { focusSearch: () => void }).focusSearch()
|
||||||
|
expect(document.activeElement).toBe(search.element)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('BoardToolbar 归档', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
setActivePinia(createPinia())
|
||||||
|
})
|
||||||
|
|
||||||
|
async function makeArchivable(store: ReturnType<typeof useTaskStore>) {
|
||||||
|
const task = store.createTask({ title: '很久前完成', status: 'done' })
|
||||||
|
store.updateTask(task?.id ?? '', {
|
||||||
|
completedAt: dayjs().subtract(30, 'day').toISOString(),
|
||||||
|
})
|
||||||
|
return task
|
||||||
|
}
|
||||||
|
|
||||||
|
it('没有可归档任务时不显示归档按钮', () => {
|
||||||
|
const { wrapper } = setup()
|
||||||
|
expect(buttonByText(wrapper, '个已完成')).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('刚完成的任务不进入可归档范围', async () => {
|
||||||
|
const { store, wrapper } = setup()
|
||||||
|
store.createTask({ title: '刚完成', status: 'done' })
|
||||||
|
await wrapper.vm.$nextTick()
|
||||||
|
expect(buttonByText(wrapper, '个已完成')).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('有可归档任务时显示数量并 emit archive', async () => {
|
||||||
|
const { store, wrapper } = setup()
|
||||||
|
await makeArchivable(store)
|
||||||
|
await wrapper.vm.$nextTick()
|
||||||
|
|
||||||
|
const button = buttonByText(wrapper, '个已完成')
|
||||||
|
expect(button?.text()).toContain('归档 1 个已完成')
|
||||||
|
|
||||||
|
await button?.trigger('click')
|
||||||
|
expect(wrapper.emitted('archive')).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('「显示归档」切换视图开关', async () => {
|
||||||
|
const { filters, wrapper } = setup()
|
||||||
|
|
||||||
|
await buttonByText(wrapper, '显示归档')?.trigger('click')
|
||||||
|
expect(filters.includeArchived).toBe(true)
|
||||||
|
|
||||||
|
await buttonByText(wrapper, '显示归档')?.trigger('click')
|
||||||
|
expect(filters.includeArchived).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { storeToRefs } from 'pinia'
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
|
||||||
|
import { useFilterStore } from '@/stores/filters'
|
||||||
|
import { useTaskStore } from '@/stores/tasks'
|
||||||
|
import { PRIORITY_LABELS, type Priority } from '@/types/task'
|
||||||
|
import { isDueToday, isOverdue } from '@/utils/date'
|
||||||
|
import { findArchivableTasks } from '@/utils/archive'
|
||||||
|
|
||||||
|
const emit = defineEmits<{ 'open-backup': []; 'open-help': []; archive: [] }>()
|
||||||
|
|
||||||
|
const store = useTaskStore()
|
||||||
|
const filters = useFilterStore()
|
||||||
|
const { keyword, priorities, tags, due, active, includeArchived } = storeToRefs(filters)
|
||||||
|
|
||||||
|
const searchRef = ref<HTMLInputElement | null>(null)
|
||||||
|
|
||||||
|
/** 由高到低排列,与看板中「高优先级优先处理」的心智一致 */
|
||||||
|
const PRIORITY_ORDER: readonly Priority[] = [3, 2, 1]
|
||||||
|
|
||||||
|
const dueTodayCount = computed(
|
||||||
|
() => store.tasks.filter((task) => isDueToday(task.dueDate, task.status)).length,
|
||||||
|
)
|
||||||
|
|
||||||
|
const overdueCount = computed(
|
||||||
|
() => store.tasks.filter((task) => isOverdue(task.dueDate, task.status)).length,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** 从现有任务里收集标签,作为筛选候选项 */
|
||||||
|
const allTags = computed(() => {
|
||||||
|
const collected = new Set<string>()
|
||||||
|
for (const task of store.tasks) {
|
||||||
|
for (const tag of task.tags) collected.add(tag)
|
||||||
|
}
|
||||||
|
return [...collected].sort((a, b) => a.localeCompare(b, 'zh-Hans-CN'))
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 当前可归档(已完成且超过保留天数)的任务数量 */
|
||||||
|
const archivableCount = computed(() => findArchivableTasks(store.tasks).length)
|
||||||
|
|
||||||
|
function focusSearch(): void {
|
||||||
|
searchRef.value?.focus()
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({ focusSearch })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<header class="flex flex-col gap-3">
|
||||||
|
<div class="flex flex-wrap items-baseline gap-x-3 gap-y-1">
|
||||||
|
<span class="text-sm text-slate-500">共 {{ store.tasks.length }} 个任务</span>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded px-1.5 py-0.5 text-sm transition"
|
||||||
|
:class="
|
||||||
|
due === 'today' ? 'bg-amber-100 text-amber-800' : 'text-slate-500 hover:bg-slate-200'
|
||||||
|
"
|
||||||
|
:aria-pressed="due === 'today'"
|
||||||
|
@click="filters.toggleDue('today')"
|
||||||
|
>
|
||||||
|
今日到期 {{ dueTodayCount }}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded px-1.5 py-0.5 text-sm transition"
|
||||||
|
:class="
|
||||||
|
due === 'overdue'
|
||||||
|
? 'bg-rose-100 text-rose-700'
|
||||||
|
: overdueCount > 0
|
||||||
|
? 'text-rose-600 hover:bg-slate-200'
|
||||||
|
: 'text-slate-500 hover:bg-slate-200'
|
||||||
|
"
|
||||||
|
:aria-pressed="due === 'overdue'"
|
||||||
|
@click="filters.toggleDue('overdue')"
|
||||||
|
>
|
||||||
|
逾期 {{ overdueCount }}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="ml-auto flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-md border border-slate-300 px-2 py-0.5 text-sm text-slate-500 transition hover:bg-white"
|
||||||
|
aria-label="快捷键帮助"
|
||||||
|
@click="emit('open-help')"
|
||||||
|
>
|
||||||
|
?
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-md border border-slate-300 px-2.5 py-1 text-sm text-slate-600 transition hover:bg-white"
|
||||||
|
@click="emit('open-backup')"
|
||||||
|
>
|
||||||
|
备份
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<input
|
||||||
|
ref="searchRef"
|
||||||
|
v-model="keyword"
|
||||||
|
type="search"
|
||||||
|
class="w-full max-w-xs rounded-md border border-slate-300 px-2.5 py-1.5 text-sm text-slate-700 outline-none placeholder:text-slate-400 focus:border-sky-400"
|
||||||
|
placeholder="搜索标题或描述(/ 聚焦)"
|
||||||
|
aria-label="搜索任务"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
v-for="value in PRIORITY_ORDER"
|
||||||
|
:key="value"
|
||||||
|
type="button"
|
||||||
|
class="rounded-md border px-2 py-1 text-xs transition"
|
||||||
|
:class="
|
||||||
|
priorities.includes(value)
|
||||||
|
? 'border-sky-400 bg-sky-50 text-sky-700'
|
||||||
|
: 'border-slate-300 text-slate-600 hover:bg-white'
|
||||||
|
"
|
||||||
|
:aria-pressed="priorities.includes(value)"
|
||||||
|
@click="filters.togglePriority(value)"
|
||||||
|
>
|
||||||
|
{{ PRIORITY_LABELS[value] }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
v-for="tag in allTags"
|
||||||
|
:key="tag"
|
||||||
|
type="button"
|
||||||
|
class="rounded-full border px-2 py-0.5 text-xs transition"
|
||||||
|
:class="
|
||||||
|
tags.includes(tag)
|
||||||
|
? 'border-sky-400 bg-sky-50 text-sky-700'
|
||||||
|
: 'border-slate-300 text-slate-600 hover:bg-white'
|
||||||
|
"
|
||||||
|
:aria-pressed="tags.includes(tag)"
|
||||||
|
@click="filters.toggleTag(tag)"
|
||||||
|
>
|
||||||
|
#{{ tag }}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
v-if="active"
|
||||||
|
type="button"
|
||||||
|
class="rounded-md px-2 py-1 text-xs text-slate-500 underline-offset-2 transition hover:text-slate-700 hover:underline"
|
||||||
|
@click="filters.clear()"
|
||||||
|
>
|
||||||
|
清空筛选
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="ml-auto flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
v-if="archivableCount > 0"
|
||||||
|
type="button"
|
||||||
|
class="rounded-md border border-slate-300 px-2 py-1 text-xs text-slate-600 transition hover:bg-white"
|
||||||
|
@click="emit('archive')"
|
||||||
|
>
|
||||||
|
归档 {{ archivableCount }} 个已完成
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-md border px-2 py-1 text-xs transition"
|
||||||
|
:class="
|
||||||
|
includeArchived
|
||||||
|
? 'border-sky-400 bg-sky-50 text-sky-700'
|
||||||
|
: 'border-slate-300 text-slate-600 hover:bg-white'
|
||||||
|
"
|
||||||
|
:aria-pressed="includeArchived"
|
||||||
|
@click="filters.toggleArchived()"
|
||||||
|
>
|
||||||
|
显示归档
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { mount } from '@vue/test-utils'
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
import DatePicker from './DatePicker.vue'
|
||||||
|
|
||||||
|
function mountPicker(modelValue: string | null, label?: string) {
|
||||||
|
return mount(DatePicker, { props: label === undefined ? { modelValue } : { modelValue, label } })
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('DatePicker', () => {
|
||||||
|
it('使用原生 date 输入,保证输出为 YYYY-MM-DD', () => {
|
||||||
|
expect(mountPicker(null).find('input').attributes('type')).toBe('date')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('渲染已有值', () => {
|
||||||
|
expect((mountPicker('2026-10-20').find('input').element as HTMLInputElement).value).toBe(
|
||||||
|
'2026-10-20',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('无值时输入框为空且不显示清除按钮', () => {
|
||||||
|
const wrapper = mountPicker(null)
|
||||||
|
expect((wrapper.find('input').element as HTMLInputElement).value).toBe('')
|
||||||
|
expect(wrapper.find('button').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('有值时显示清除按钮', () => {
|
||||||
|
expect(mountPicker('2026-10-20').find('button').exists()).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('选择日期后 emit 字符串', async () => {
|
||||||
|
const wrapper = mountPicker(null)
|
||||||
|
const input = wrapper.find('input')
|
||||||
|
|
||||||
|
// 注意:VTU 的 setValue 会同时触发 input 与 change,无需再手动 trigger('change')
|
||||||
|
await input.setValue('2026-10-20')
|
||||||
|
|
||||||
|
expect(wrapper.emitted('update:modelValue')).toEqual([['2026-10-20']])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('清空输入时 emit null', async () => {
|
||||||
|
const wrapper = mountPicker('2026-10-20')
|
||||||
|
const input = wrapper.find('input')
|
||||||
|
|
||||||
|
await input.setValue('')
|
||||||
|
|
||||||
|
expect(wrapper.emitted('update:modelValue')).toEqual([[null]])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('点击清除按钮 emit null', async () => {
|
||||||
|
const wrapper = mountPicker('2026-10-20')
|
||||||
|
await wrapper.find('button').trigger('click')
|
||||||
|
expect(wrapper.emitted('update:modelValue')).toEqual([[null]])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('默认带「截止日期」可访问性标签,可覆盖', () => {
|
||||||
|
expect(mountPicker(null).find('input').attributes('aria-label')).toBe('截止日期')
|
||||||
|
expect(mountPicker(null, '开始日期').find('input').attributes('aria-label')).toBe('开始日期')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* 日期选择器(T09)
|
||||||
|
*
|
||||||
|
* 直接使用浏览器原生 `<input type="date">`:
|
||||||
|
* - 其 value 规范就是 YYYY-MM-DD,与 ADR-003 的存储约定天然一致
|
||||||
|
* - 自带可访问性、键盘操作与本地化日历,无需自己实现
|
||||||
|
*/
|
||||||
|
|
||||||
|
defineProps<{ modelValue: string | null; label?: string }>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{ 'update:modelValue': [value: string | null] }>()
|
||||||
|
|
||||||
|
function onChange(event: Event): void {
|
||||||
|
const value = (event.target as HTMLInputElement).value
|
||||||
|
emit('update:modelValue', value === '' ? null : value)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
class="min-w-0 flex-1 rounded-md border border-slate-300 px-2.5 py-1.5 text-sm text-slate-700 outline-none focus:border-sky-400"
|
||||||
|
:value="modelValue ?? ''"
|
||||||
|
:aria-label="label ?? '截止日期'"
|
||||||
|
@change="onChange"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
v-if="modelValue"
|
||||||
|
type="button"
|
||||||
|
class="shrink-0 rounded px-2 py-1 text-xs text-slate-500 transition hover:bg-slate-100"
|
||||||
|
@click="emit('update:modelValue', null)"
|
||||||
|
>
|
||||||
|
清除
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { mount } from '@vue/test-utils'
|
||||||
|
import type { Ref } from 'vue'
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
import PwaUpdatePrompt from './PwaUpdatePrompt.vue'
|
||||||
|
|
||||||
|
/** 用 hoisted 持有引用,让测试可以驱动 needRefresh / offlineReady */
|
||||||
|
const stub = vi.hoisted(() => ({
|
||||||
|
needRefresh: null as unknown as Ref<boolean>,
|
||||||
|
offlineReady: null as unknown as Ref<boolean>,
|
||||||
|
updateServiceWorker: null as unknown as ReturnType<typeof vi.fn>,
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('virtual:pwa-register/vue', async () => {
|
||||||
|
const { ref } = await import('vue')
|
||||||
|
stub.needRefresh = ref(false)
|
||||||
|
stub.offlineReady = ref(false)
|
||||||
|
stub.updateServiceWorker = vi.fn()
|
||||||
|
return { useRegisterSW: () => stub }
|
||||||
|
})
|
||||||
|
|
||||||
|
function mountPrompt() {
|
||||||
|
return mount(PwaUpdatePrompt, { attachTo: document.body })
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('PwaUpdatePrompt', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
stub.needRefresh.value = false
|
||||||
|
stub.offlineReady.value = false
|
||||||
|
stub.updateServiceWorker.mockClear()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('无状态时不渲染', () => {
|
||||||
|
const wrapper = mountPrompt()
|
||||||
|
expect(wrapper.find('[role="status"]').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('有新版本时提示并可立即更新', async () => {
|
||||||
|
stub.needRefresh.value = true
|
||||||
|
const wrapper = mountPrompt()
|
||||||
|
await wrapper.vm.$nextTick()
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('有新版本可用')
|
||||||
|
|
||||||
|
const updateButton = wrapper.findAll('button').find((node) => node.text() === '立即更新')
|
||||||
|
await updateButton?.trigger('click')
|
||||||
|
|
||||||
|
// 传 true 表示更新后立即刷新页面
|
||||||
|
expect(stub.updateServiceWorker).toHaveBeenCalledWith(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('已可离线使用时提示,但不显示更新按钮', async () => {
|
||||||
|
stub.offlineReady.value = true
|
||||||
|
const wrapper = mountPrompt()
|
||||||
|
await wrapper.vm.$nextTick()
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('已缓存,可离线使用')
|
||||||
|
expect(wrapper.findAll('button').map((node) => node.text())).not.toContain('立即更新')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('点击关闭后提示消失', async () => {
|
||||||
|
stub.needRefresh.value = true
|
||||||
|
const wrapper = mountPrompt()
|
||||||
|
await wrapper.vm.$nextTick()
|
||||||
|
|
||||||
|
await wrapper.find('button[aria-label="关闭提示"]').trigger('click')
|
||||||
|
await wrapper.vm.$nextTick()
|
||||||
|
|
||||||
|
expect(stub.needRefresh.value).toBe(false)
|
||||||
|
expect(wrapper.find('[role="status"]').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('带 role=status 便于读屏播报', async () => {
|
||||||
|
stub.offlineReady.value = true
|
||||||
|
const wrapper = mountPrompt()
|
||||||
|
await wrapper.vm.$nextTick()
|
||||||
|
expect(wrapper.find('[role="status"]').exists()).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* PWA 更新提示
|
||||||
|
*
|
||||||
|
* registerType 为 prompt,因此新版本不会自动生效 ——
|
||||||
|
* 由用户决定何时刷新,避免打断正在进行的编辑。
|
||||||
|
*/
|
||||||
|
import { useRegisterSW } from 'virtual:pwa-register/vue'
|
||||||
|
import { computed } from 'vue'
|
||||||
|
|
||||||
|
const { needRefresh, offlineReady, updateServiceWorker } = useRegisterSW()
|
||||||
|
|
||||||
|
const visible = computed(() => needRefresh.value || offlineReady.value)
|
||||||
|
|
||||||
|
function dismiss(): void {
|
||||||
|
needRefresh.value = false
|
||||||
|
offlineReady.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function reload(): void {
|
||||||
|
void updateServiceWorker(true)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div v-if="visible" class="fixed inset-x-0 bottom-4 z-50 flex justify-center px-4">
|
||||||
|
<div
|
||||||
|
role="status"
|
||||||
|
class="flex items-center gap-3 rounded-md bg-slate-800 px-3 py-2 text-sm text-white shadow-lg"
|
||||||
|
>
|
||||||
|
<span>{{ needRefresh ? '有新版本可用' : '已缓存,可离线使用' }}</span>
|
||||||
|
|
||||||
|
<button
|
||||||
|
v-if="needRefresh"
|
||||||
|
type="button"
|
||||||
|
class="shrink-0 rounded px-2 py-0.5 font-medium text-sky-300 transition hover:bg-white/10"
|
||||||
|
@click="reload"
|
||||||
|
>
|
||||||
|
立即更新
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="shrink-0 rounded px-1.5 text-slate-400 transition hover:text-white"
|
||||||
|
aria-label="关闭提示"
|
||||||
|
@click="dismiss"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import { mount } from '@vue/test-utils'
|
||||||
|
import { createPinia, setActivePinia } from 'pinia'
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
import { createMemoryTaskRepository } from '@/data/taskRepository'
|
||||||
|
import { useTaskStore } from '@/stores/tasks'
|
||||||
|
import type { ColumnId } from '@/types/task'
|
||||||
|
|
||||||
|
import QuickAddTask from './QuickAddTask.vue'
|
||||||
|
|
||||||
|
function setup(status: ColumnId = 'todo') {
|
||||||
|
const pinia = createPinia()
|
||||||
|
setActivePinia(pinia)
|
||||||
|
const store = useTaskStore()
|
||||||
|
store.init({ repository: createMemoryTaskRepository(), crossTab: false })
|
||||||
|
|
||||||
|
const wrapper = mount(QuickAddTask, {
|
||||||
|
props: { status },
|
||||||
|
global: { plugins: [pinia] },
|
||||||
|
attachTo: document.body,
|
||||||
|
})
|
||||||
|
return { store, wrapper }
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('QuickAddTask', () => {
|
||||||
|
it('提交后创建任务到指定列并清空输入', async () => {
|
||||||
|
const { store, wrapper } = setup('doing')
|
||||||
|
const input = wrapper.find('input')
|
||||||
|
|
||||||
|
await input.setValue('新任务')
|
||||||
|
await wrapper.find('form').trigger('submit')
|
||||||
|
|
||||||
|
expect(store.tasks.map((task) => task.title)).toEqual(['新任务'])
|
||||||
|
expect(store.byColumn.doing.map((task) => task.title)).toEqual(['新任务'])
|
||||||
|
expect((input.element as HTMLInputElement).value).toBe('')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('提交后保持聚焦,便于连续录入', async () => {
|
||||||
|
const { wrapper } = setup()
|
||||||
|
const input = wrapper.find('input')
|
||||||
|
|
||||||
|
await input.setValue('第一条')
|
||||||
|
await wrapper.find('form').trigger('submit')
|
||||||
|
|
||||||
|
expect(document.activeElement).toBe(input.element)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('连续录入两条,后者位于列首', async () => {
|
||||||
|
const { store, wrapper } = setup()
|
||||||
|
const input = wrapper.find('input')
|
||||||
|
|
||||||
|
await input.setValue('第一条')
|
||||||
|
await wrapper.find('form').trigger('submit')
|
||||||
|
await input.setValue('第二条')
|
||||||
|
await wrapper.find('form').trigger('submit')
|
||||||
|
|
||||||
|
expect(store.byColumn.todo.map((task) => task.title)).toEqual(['第二条', '第一条'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('空白输入不创建任务', async () => {
|
||||||
|
const { store, wrapper } = setup()
|
||||||
|
const input = wrapper.find('input')
|
||||||
|
|
||||||
|
await input.setValue(' ')
|
||||||
|
await wrapper.find('form').trigger('submit')
|
||||||
|
expect(store.tasks).toHaveLength(0)
|
||||||
|
|
||||||
|
await wrapper.find('form').trigger('submit')
|
||||||
|
expect(store.tasks).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('输入法组合期间回车不提交(中文选词不误触发)', async () => {
|
||||||
|
const { store, wrapper } = setup()
|
||||||
|
const input = wrapper.find('input')
|
||||||
|
|
||||||
|
await input.trigger('compositionstart')
|
||||||
|
await input.setValue('zhongwen')
|
||||||
|
await wrapper.find('form').trigger('submit')
|
||||||
|
|
||||||
|
expect(store.tasks).toHaveLength(0)
|
||||||
|
|
||||||
|
await input.trigger('compositionend')
|
||||||
|
await input.setValue('中文')
|
||||||
|
await wrapper.find('form').trigger('submit')
|
||||||
|
|
||||||
|
expect(store.tasks.map((task) => task.title)).toEqual(['中文'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('标题首尾空白被去除后入库', async () => {
|
||||||
|
const { store, wrapper } = setup()
|
||||||
|
await wrapper.find('input').setValue(' 写方案 ')
|
||||||
|
await wrapper.find('form').trigger('submit')
|
||||||
|
expect(store.tasks[0]?.title).toBe('写方案')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('输入框带可访问性标签', () => {
|
||||||
|
const { wrapper } = setup()
|
||||||
|
expect(wrapper.find('input').attributes('aria-label')).toBe('添加任务')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
import { useTaskStore } from '@/stores/tasks'
|
||||||
|
import type { ColumnId } from '@/types/task'
|
||||||
|
|
||||||
|
const props = defineProps<{ status: ColumnId }>()
|
||||||
|
|
||||||
|
const store = useTaskStore()
|
||||||
|
const title = ref('')
|
||||||
|
const inputRef = ref<HTMLInputElement | null>(null)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 中文输入法保护:使用拼音输入时,回车用于「选词」而非「提交」。
|
||||||
|
* 若不屏蔽,选词的回车会误触发提交,导致录入半截标题。
|
||||||
|
*/
|
||||||
|
const composing = ref(false)
|
||||||
|
|
||||||
|
function submit(): void {
|
||||||
|
const created = store.createTask({ title: title.value, status: props.status })
|
||||||
|
if (!created) return
|
||||||
|
title.value = ''
|
||||||
|
inputRef.value?.focus()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 供快捷键(N)调用 */
|
||||||
|
function focusInput(): void {
|
||||||
|
inputRef.value?.focus()
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({ focusInput })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<form class="px-2 pb-2" @submit.prevent="composing ? undefined : submit()">
|
||||||
|
<input
|
||||||
|
ref="inputRef"
|
||||||
|
v-model="title"
|
||||||
|
type="text"
|
||||||
|
class="w-full rounded-md border border-transparent bg-white/60 px-2.5 py-1.5 text-sm text-slate-700 transition outline-none placeholder:text-slate-400 focus:border-sky-400 focus:bg-white"
|
||||||
|
placeholder="+ 添加任务,回车确认"
|
||||||
|
aria-label="添加任务"
|
||||||
|
@compositionstart="composing = true"
|
||||||
|
@compositionend="composing = false"
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import { mount } from '@vue/test-utils'
|
||||||
|
import { nextTick } from 'vue'
|
||||||
|
import { afterEach, describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
import SideDrawer from './SideDrawer.vue'
|
||||||
|
|
||||||
|
const mounted: Array<{ unmount: () => void }> = []
|
||||||
|
|
||||||
|
function mountDrawer(props: { open: boolean; title?: string } = { open: true }) {
|
||||||
|
const wrapper = mount(SideDrawer, {
|
||||||
|
props: { title: '任务详情', ...props },
|
||||||
|
slots: { default: '<button type="button">内部按钮</button>' },
|
||||||
|
global: { stubs: { teleport: true } },
|
||||||
|
attachTo: document.body,
|
||||||
|
})
|
||||||
|
mounted.push(wrapper)
|
||||||
|
return wrapper
|
||||||
|
}
|
||||||
|
|
||||||
|
/** watcher 内部还会再等一次 nextTick,这里统一多刷一轮 */
|
||||||
|
async function flush(): Promise<void> {
|
||||||
|
await nextTick()
|
||||||
|
await nextTick()
|
||||||
|
}
|
||||||
|
|
||||||
|
function pressEscape(): void {
|
||||||
|
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }))
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
while (mounted.length > 0) mounted.pop()?.unmount()
|
||||||
|
document.body.innerHTML = ''
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('SideDrawer 渲染', () => {
|
||||||
|
it('open 为 false 时不渲染面板', () => {
|
||||||
|
const wrapper = mountDrawer({ open: false })
|
||||||
|
expect(wrapper.find('[role="dialog"]').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('open 为 true 时渲染标题与插槽内容', () => {
|
||||||
|
const wrapper = mountDrawer({ open: true })
|
||||||
|
expect(wrapper.find('[role="dialog"]').exists()).toBe(true)
|
||||||
|
expect(wrapper.text()).toContain('任务详情')
|
||||||
|
expect(wrapper.text()).toContain('内部按钮')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('面板带 aria-modal 属性', () => {
|
||||||
|
const wrapper = mountDrawer({ open: true })
|
||||||
|
expect(wrapper.find('[role="dialog"]').attributes('aria-modal')).toBe('true')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('渲染 footer 插槽', () => {
|
||||||
|
const wrapper = mount(SideDrawer, {
|
||||||
|
props: { open: true },
|
||||||
|
slots: { footer: '<span>底部操作</span>' },
|
||||||
|
global: { stubs: { teleport: true } },
|
||||||
|
})
|
||||||
|
expect(wrapper.text()).toContain('底部操作')
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('SideDrawer 关闭方式', () => {
|
||||||
|
it('点击关闭按钮 emit close', async () => {
|
||||||
|
const wrapper = mountDrawer({ open: true })
|
||||||
|
await wrapper.find('button[aria-label="关闭"]').trigger('click')
|
||||||
|
expect(wrapper.emitted('close')).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('点击遮罩 emit close', async () => {
|
||||||
|
const wrapper = mountDrawer({ open: true })
|
||||||
|
await wrapper.find('.absolute.inset-0').trigger('click')
|
||||||
|
expect(wrapper.emitted('close')).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('按 ESC emit close', async () => {
|
||||||
|
const wrapper = mountDrawer({ open: true })
|
||||||
|
pressEscape()
|
||||||
|
await nextTick()
|
||||||
|
expect(wrapper.emitted('close')).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('关闭后不再响应 ESC', async () => {
|
||||||
|
const wrapper = mountDrawer({ open: true })
|
||||||
|
await wrapper.setProps({ open: false })
|
||||||
|
pressEscape()
|
||||||
|
await nextTick()
|
||||||
|
expect(wrapper.emitted('close')).toBeUndefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('SideDrawer 焦点管理', () => {
|
||||||
|
it('打开后焦点移入面板内的第一个可聚焦元素', async () => {
|
||||||
|
mountDrawer({ open: true })
|
||||||
|
await flush()
|
||||||
|
expect(document.activeElement?.getAttribute('aria-label')).toBe('关闭')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('关闭后焦点回到打开前的元素', async () => {
|
||||||
|
const trigger = document.createElement('button')
|
||||||
|
trigger.textContent = '触发按钮'
|
||||||
|
document.body.appendChild(trigger)
|
||||||
|
trigger.focus()
|
||||||
|
expect(document.activeElement).toBe(trigger)
|
||||||
|
|
||||||
|
const wrapper = mountDrawer({ open: false })
|
||||||
|
await wrapper.setProps({ open: true })
|
||||||
|
await flush()
|
||||||
|
expect(document.activeElement).not.toBe(trigger)
|
||||||
|
|
||||||
|
await wrapper.setProps({ open: false })
|
||||||
|
expect(document.activeElement).toBe(trigger)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Tab 焦点陷阱:在最后一个元素上按 Tab 回到第一个', async () => {
|
||||||
|
const wrapper = mountDrawer({ open: true })
|
||||||
|
await flush()
|
||||||
|
|
||||||
|
const buttons = wrapper.findAll('button')
|
||||||
|
const first = buttons[0]
|
||||||
|
const last = buttons[buttons.length - 1]
|
||||||
|
expect(buttons.length).toBeGreaterThan(1)
|
||||||
|
|
||||||
|
;(last?.element as HTMLElement).focus()
|
||||||
|
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', cancelable: true }))
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
expect(document.activeElement).toBe(first?.element)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { nextTick, onBeforeUnmount, ref, watch } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps<{ open: boolean; title?: string }>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{ close: [] }>()
|
||||||
|
|
||||||
|
const panelRef = ref<HTMLElement | null>(null)
|
||||||
|
let previouslyFocused: HTMLElement | null = null
|
||||||
|
|
||||||
|
const FOCUSABLE_SELECTOR = [
|
||||||
|
'a[href]',
|
||||||
|
'button:not([disabled])',
|
||||||
|
'input:not([disabled])',
|
||||||
|
'select:not([disabled])',
|
||||||
|
'textarea:not([disabled])',
|
||||||
|
'[tabindex]:not([tabindex="-1"])',
|
||||||
|
].join(', ')
|
||||||
|
|
||||||
|
function focusableElements(): HTMLElement[] {
|
||||||
|
const root = panelRef.value
|
||||||
|
if (!root) return []
|
||||||
|
return Array.from(root.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ESC 关闭 + Tab 焦点陷阱 */
|
||||||
|
function onDocumentKeydown(event: KeyboardEvent): void {
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
event.preventDefault()
|
||||||
|
emit('close')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key !== 'Tab') return
|
||||||
|
|
||||||
|
const items = focusableElements()
|
||||||
|
if (items.length === 0) {
|
||||||
|
event.preventDefault()
|
||||||
|
panelRef.value?.focus()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const first = items[0]
|
||||||
|
const last = items[items.length - 1]
|
||||||
|
const active = document.activeElement as HTMLElement | null
|
||||||
|
const inside = active !== null && items.includes(active)
|
||||||
|
|
||||||
|
if (event.shiftKey) {
|
||||||
|
if (!inside || active === first) {
|
||||||
|
event.preventDefault()
|
||||||
|
last?.focus()
|
||||||
|
}
|
||||||
|
} else if (!inside || active === last) {
|
||||||
|
event.preventDefault()
|
||||||
|
first?.focus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopListening(): void {
|
||||||
|
document.removeEventListener('keydown', onDocumentKeydown, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.open,
|
||||||
|
(isOpen) => {
|
||||||
|
if (isOpen) {
|
||||||
|
previouslyFocused = document.activeElement as HTMLElement | null
|
||||||
|
document.addEventListener('keydown', onDocumentKeydown, true)
|
||||||
|
void nextTick(() => {
|
||||||
|
const target = focusableElements()[0] ?? panelRef.value
|
||||||
|
target?.focus()
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
stopListening()
|
||||||
|
previouslyFocused?.focus?.()
|
||||||
|
previouslyFocused = null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
onBeforeUnmount(stopListening)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<div v-if="open" class="fixed inset-0 z-40">
|
||||||
|
<div class="absolute inset-0 bg-slate-900/40" @click="emit('close')" />
|
||||||
|
|
||||||
|
<aside
|
||||||
|
ref="panelRef"
|
||||||
|
class="absolute inset-y-0 right-0 flex w-full max-w-md flex-col bg-white shadow-xl outline-none"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
:aria-label="title"
|
||||||
|
tabindex="-1"
|
||||||
|
>
|
||||||
|
<header class="flex items-center justify-between border-b border-slate-200 px-4 py-3">
|
||||||
|
<h2 class="text-base font-medium text-slate-800">{{ title }}</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded px-2 py-1 text-slate-400 transition hover:bg-slate-100 hover:text-slate-600"
|
||||||
|
aria-label="关闭"
|
||||||
|
@click="emit('close')"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="min-h-0 flex-1 overflow-y-auto p-4">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer v-if="$slots.footer" class="border-t border-slate-200 px-4 py-3">
|
||||||
|
<slot name="footer" />
|
||||||
|
</footer>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
import { mount } from '@vue/test-utils'
|
||||||
|
import type { VueWrapper } from '@vue/test-utils'
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
import TagInput from './TagInput.vue'
|
||||||
|
|
||||||
|
function mountTags(modelValue: string[] = []) {
|
||||||
|
return mount(TagInput, { props: { modelValue } })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 显式指定 key,避免依赖测试工具对修饰符的解析方式 */
|
||||||
|
async function pressEnter(wrapper: VueWrapper): Promise<void> {
|
||||||
|
await wrapper.find('input').trigger('keydown', { key: 'Enter' })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pressBackspace(wrapper: VueWrapper): Promise<void> {
|
||||||
|
await wrapper.find('input').trigger('keydown', { key: 'Backspace' })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 取最后一次 emit 的数组值 */
|
||||||
|
function lastEmitted(wrapper: VueWrapper): string[] | undefined {
|
||||||
|
const events = wrapper.emitted('update:modelValue')
|
||||||
|
if (!events) return undefined
|
||||||
|
return events[events.length - 1]?.[0] as string[] | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('TagInput 渲染', () => {
|
||||||
|
it('渲染已有标签', () => {
|
||||||
|
const wrapper = mountTags(['设计', '前端'])
|
||||||
|
expect(wrapper.text()).toContain('#设计')
|
||||||
|
expect(wrapper.text()).toContain('#前端')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('无标签时显示占位提示', () => {
|
||||||
|
expect(mountTags([]).find('input').attributes('placeholder')).toBe('回车添加标签')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('有标签时隐藏占位提示', () => {
|
||||||
|
expect(mountTags(['a']).find('input').attributes('placeholder')).toBe('')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('带可访问性标签', () => {
|
||||||
|
expect(mountTags([]).find('input').attributes('aria-label')).toBe('添加标签')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('TagInput 添加', () => {
|
||||||
|
it('回车添加标签', async () => {
|
||||||
|
const wrapper = mountTags([])
|
||||||
|
await wrapper.find('input').setValue('设计')
|
||||||
|
await pressEnter(wrapper)
|
||||||
|
expect(lastEmitted(wrapper)).toEqual(['设计'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('添加后清空输入框', async () => {
|
||||||
|
const wrapper = mountTags([])
|
||||||
|
const input = wrapper.find('input')
|
||||||
|
await input.setValue('设计')
|
||||||
|
await pressEnter(wrapper)
|
||||||
|
expect((input.element as HTMLInputElement).value).toBe('')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('去除首尾空白', async () => {
|
||||||
|
const wrapper = mountTags([])
|
||||||
|
await wrapper.find('input').setValue(' 设计 ')
|
||||||
|
await pressEnter(wrapper)
|
||||||
|
expect(lastEmitted(wrapper)).toEqual(['设计'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('空白输入不添加', async () => {
|
||||||
|
const wrapper = mountTags([])
|
||||||
|
await wrapper.find('input').setValue(' ')
|
||||||
|
await pressEnter(wrapper)
|
||||||
|
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('重复标签不添加', async () => {
|
||||||
|
const wrapper = mountTags(['设计'])
|
||||||
|
await wrapper.find('input').setValue('设计')
|
||||||
|
await pressEnter(wrapper)
|
||||||
|
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('追加到已有标签之后', async () => {
|
||||||
|
const wrapper = mountTags(['设计'])
|
||||||
|
await wrapper.find('input').setValue('前端')
|
||||||
|
await pressEnter(wrapper)
|
||||||
|
expect(lastEmitted(wrapper)).toEqual(['设计', '前端'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('输入法组合期间回车不添加', async () => {
|
||||||
|
const wrapper = mountTags([])
|
||||||
|
const input = wrapper.find('input')
|
||||||
|
|
||||||
|
await input.trigger('compositionstart')
|
||||||
|
await input.setValue('sheji')
|
||||||
|
await pressEnter(wrapper)
|
||||||
|
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
|
||||||
|
|
||||||
|
await input.trigger('compositionend')
|
||||||
|
await input.setValue('设计')
|
||||||
|
await pressEnter(wrapper)
|
||||||
|
expect(lastEmitted(wrapper)).toEqual(['设计'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('失焦时提交未确认的草稿', async () => {
|
||||||
|
const wrapper = mountTags([])
|
||||||
|
await wrapper.find('input').setValue('草稿')
|
||||||
|
await wrapper.find('input').trigger('blur')
|
||||||
|
expect(lastEmitted(wrapper)).toEqual(['草稿'])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('TagInput 删除', () => {
|
||||||
|
it('点击 ✕ 移除对应标签', async () => {
|
||||||
|
const wrapper = mountTags(['设计', '前端'])
|
||||||
|
await wrapper.find('button[aria-label="移除标签 设计"]').trigger('click')
|
||||||
|
expect(lastEmitted(wrapper)).toEqual(['前端'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('输入框为空时退格删除最后一个标签', async () => {
|
||||||
|
const wrapper = mountTags(['设计', '前端'])
|
||||||
|
await pressBackspace(wrapper)
|
||||||
|
expect(lastEmitted(wrapper)).toEqual(['设计'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('输入框非空时退格不删除标签', async () => {
|
||||||
|
const wrapper = mountTags(['设计'])
|
||||||
|
await wrapper.find('input').setValue('x')
|
||||||
|
await pressBackspace(wrapper)
|
||||||
|
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('没有标签时退格不报错', async () => {
|
||||||
|
const wrapper = mountTags([])
|
||||||
|
await expect(pressBackspace(wrapper)).resolves.toBeUndefined()
|
||||||
|
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps<{ modelValue: string[]; placeholder?: string }>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{ 'update:modelValue': [value: string[]] }>()
|
||||||
|
|
||||||
|
const draft = ref('')
|
||||||
|
|
||||||
|
/** 中文输入法保护:选词的回车不应被当作「添加标签」 */
|
||||||
|
const composing = ref(false)
|
||||||
|
|
||||||
|
function commit(): void {
|
||||||
|
const value = draft.value.trim()
|
||||||
|
draft.value = ''
|
||||||
|
if (!value) return
|
||||||
|
if (props.modelValue.includes(value)) return
|
||||||
|
emit('update:modelValue', [...props.modelValue, value])
|
||||||
|
}
|
||||||
|
|
||||||
|
function remove(tag: string): void {
|
||||||
|
emit(
|
||||||
|
'update:modelValue',
|
||||||
|
props.modelValue.filter((item) => item !== tag),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 输入框为空时按退格删除最后一个标签 */
|
||||||
|
function onBackspace(): void {
|
||||||
|
if (draft.value !== '') return
|
||||||
|
const last = props.modelValue[props.modelValue.length - 1]
|
||||||
|
if (last === undefined) return
|
||||||
|
remove(last)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="flex flex-wrap items-center gap-1.5 rounded-md border border-slate-300 px-2 py-1.5 focus-within:border-sky-400"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
v-for="tag in modelValue"
|
||||||
|
:key="tag"
|
||||||
|
class="flex items-center gap-1 rounded bg-sky-50 px-1.5 py-0.5 text-xs text-sky-700"
|
||||||
|
>
|
||||||
|
#{{ tag }}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="text-sky-500 transition hover:text-sky-800"
|
||||||
|
:aria-label="`移除标签 ${tag}`"
|
||||||
|
@click="remove(tag)"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<input
|
||||||
|
v-model="draft"
|
||||||
|
type="text"
|
||||||
|
class="min-w-24 flex-1 border-none bg-transparent px-1 py-0.5 text-sm text-slate-700 outline-none placeholder:text-slate-400"
|
||||||
|
:placeholder="modelValue.length === 0 ? (placeholder ?? '回车添加标签') : ''"
|
||||||
|
aria-label="添加标签"
|
||||||
|
@keydown.enter.prevent="composing ? undefined : commit()"
|
||||||
|
@keydown.backspace="onBackspace"
|
||||||
|
@compositionstart="composing = true"
|
||||||
|
@compositionend="composing = false"
|
||||||
|
@blur="commit"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
import { mount } from '@vue/test-utils'
|
||||||
|
import type { VueWrapper } from '@vue/test-utils'
|
||||||
|
import dayjs from 'dayjs'
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
import type { Task } from '@/types/task'
|
||||||
|
|
||||||
|
import TaskCard from './TaskCard.vue'
|
||||||
|
|
||||||
|
function makeTask(overrides: Partial<Task> = {}): Task {
|
||||||
|
return {
|
||||||
|
id: 'task-1',
|
||||||
|
title: '写方案',
|
||||||
|
description: '',
|
||||||
|
status: 'todo',
|
||||||
|
priority: 2,
|
||||||
|
tags: [],
|
||||||
|
dueDate: null,
|
||||||
|
sortOrder: 1024,
|
||||||
|
createdAt: '2026-10-18T00:00:00.000Z',
|
||||||
|
updatedAt: '2026-10-18T00:00:00.000Z',
|
||||||
|
completedAt: null,
|
||||||
|
archivedAt: null,
|
||||||
|
...overrides,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function mountCard(overrides: Partial<Task> = {}) {
|
||||||
|
return mount(TaskCard, { props: { task: makeTask(overrides) } })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 通过文案定位优先级徽章,避免依赖 DOM 层级 */
|
||||||
|
function priorityBadge(wrapper: VueWrapper) {
|
||||||
|
return wrapper.findAll('span').find((node) => ['低', '中', '高'].includes(node.text()))
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('TaskCard 基础渲染', () => {
|
||||||
|
it('渲染标题', () => {
|
||||||
|
expect(mountCard({ title: '写方案' }).find('h3').text()).toBe('写方案')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('渲染优先级徽章', () => {
|
||||||
|
expect(priorityBadge(mountCard({ priority: 3 }))?.text()).toBe('高')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('无标签无截止日期时仍然显示优先级徽章', () => {
|
||||||
|
const wrapper = mountCard({ priority: 1, tags: [], dueDate: null })
|
||||||
|
expect(priorityBadge(wrapper)?.text()).toBe('低')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('无截止日期时不渲染日期徽章', () => {
|
||||||
|
const wrapper = mountCard({ dueDate: null })
|
||||||
|
expect(wrapper.text()).not.toContain('今天')
|
||||||
|
expect(wrapper.text()).not.toContain('逾期')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('优先级视觉区分', () => {
|
||||||
|
it('三档优先级使用不同色系', () => {
|
||||||
|
const high = priorityBadge(mountCard({ priority: 3 }))?.classes().join(' ') ?? ''
|
||||||
|
const medium = priorityBadge(mountCard({ priority: 2 }))?.classes().join(' ') ?? ''
|
||||||
|
const low = priorityBadge(mountCard({ priority: 1 }))?.classes().join(' ') ?? ''
|
||||||
|
|
||||||
|
expect(high).toContain('rose')
|
||||||
|
expect(medium).toContain('amber')
|
||||||
|
expect(low).toContain('slate')
|
||||||
|
expect(new Set([high, medium, low]).size).toBe(3)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('截止日期', () => {
|
||||||
|
it('逾期任务使用警示样式', () => {
|
||||||
|
const yesterday = dayjs().subtract(2, 'day').format('YYYY-MM-DD')
|
||||||
|
const wrapper = mountCard({ dueDate: yesterday })
|
||||||
|
expect(wrapper.text()).toContain('逾期 2 天')
|
||||||
|
|
||||||
|
const badge = wrapper.findAll('span').find((node) => node.text().includes('逾期'))
|
||||||
|
expect(badge?.classes().join(' ')).toContain('rose')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('已完成任务不再标记逾期', () => {
|
||||||
|
const yesterday = dayjs().subtract(2, 'day').format('YYYY-MM-DD')
|
||||||
|
const wrapper = mountCard({ dueDate: yesterday, status: 'done' })
|
||||||
|
const badge = wrapper.findAll('span').find((node) => node.text().includes('逾期'))
|
||||||
|
expect(badge?.classes().join(' ')).not.toContain('rose')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('今天到期显示「今天」', () => {
|
||||||
|
expect(mountCard({ dueDate: dayjs().format('YYYY-MM-DD') }).text()).toContain('今天')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('已完成样式', () => {
|
||||||
|
it('标题加删除线并整体降饱和', () => {
|
||||||
|
const wrapper = mountCard({ status: 'done' })
|
||||||
|
expect(wrapper.find('h3').classes().join(' ')).toContain('line-through')
|
||||||
|
expect(wrapper.find('article').classes().join(' ')).toContain('opacity-60')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('未完成时不加删除线', () => {
|
||||||
|
expect(mountCard({ status: 'todo' }).find('h3').classes().join(' ')).not.toContain(
|
||||||
|
'line-through',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('标签展示', () => {
|
||||||
|
it('最多显示 3 个标签', () => {
|
||||||
|
const wrapper = mountCard({ tags: ['a', 'b', 'c', 'd', 'e'] })
|
||||||
|
expect(wrapper.text()).toContain('#a')
|
||||||
|
expect(wrapper.text()).toContain('#c')
|
||||||
|
expect(wrapper.text()).not.toContain('#d')
|
||||||
|
expect(wrapper.text()).toContain('+2')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('标签不超过 3 个时不显示折叠计数', () => {
|
||||||
|
const wrapper = mountCard({ tags: ['a', 'b', 'c'] })
|
||||||
|
expect(wrapper.text()).not.toContain('+')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('无标签时不渲染标签', () => {
|
||||||
|
expect(mountCard({ tags: [] }).text()).not.toContain('#')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('交互', () => {
|
||||||
|
it('点击完成按钮 emit toggle 并带上 id', async () => {
|
||||||
|
const wrapper = mountCard({ id: 'task-42' })
|
||||||
|
await wrapper.find('button').trigger('click')
|
||||||
|
expect(wrapper.emitted('toggle')).toEqual([['task-42']])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('完成按钮带可访问性属性', () => {
|
||||||
|
expect(mountCard({ status: 'todo' }).find('button').attributes('aria-label')).toBe(
|
||||||
|
'标记为已完成',
|
||||||
|
)
|
||||||
|
expect(mountCard({ status: 'done' }).find('button').attributes('aria-pressed')).toBe('true')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('点击卡片 emit open 而不是 toggle', async () => {
|
||||||
|
const wrapper = mountCard({ id: 'task-7' })
|
||||||
|
await wrapper.find('article').trigger('click')
|
||||||
|
expect(wrapper.emitted('open')).toEqual([['task-7']])
|
||||||
|
expect(wrapper.emitted('toggle')).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('卡片可点击(带 cursor-pointer)', () => {
|
||||||
|
expect(mountCard().find('article').classes().join(' ')).toContain('cursor-pointer')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
|
||||||
|
import { PRIORITY_LABELS, type Task } from '@/types/task'
|
||||||
|
import { formatDueDate, isOverdue } from '@/utils/date'
|
||||||
|
|
||||||
|
const MAX_VISIBLE_TAGS = 3
|
||||||
|
|
||||||
|
const props = defineProps<{ task: Task }>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{ toggle: [id: string]; open: [id: string] }>()
|
||||||
|
|
||||||
|
const isDone = computed(() => props.task.status === 'done')
|
||||||
|
const overdue = computed(() => isOverdue(props.task.dueDate, props.task.status))
|
||||||
|
const visibleTags = computed(() => props.task.tags.slice(0, MAX_VISIBLE_TAGS))
|
||||||
|
const hiddenTagCount = computed(() => Math.max(0, props.task.tags.length - MAX_VISIBLE_TAGS))
|
||||||
|
|
||||||
|
/** 三档优先级使用不同色系,保证一眼可区分 */
|
||||||
|
const priorityClass = computed(() => {
|
||||||
|
switch (props.task.priority) {
|
||||||
|
case 3:
|
||||||
|
return 'bg-rose-100 text-rose-700'
|
||||||
|
case 2:
|
||||||
|
return 'bg-amber-100 text-amber-700'
|
||||||
|
default:
|
||||||
|
return 'bg-slate-100 text-slate-500'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<article
|
||||||
|
class="cursor-pointer rounded-md border border-slate-200 bg-white px-2.5 py-2 shadow-sm transition"
|
||||||
|
:class="isDone ? 'opacity-60' : 'hover:border-slate-300 hover:shadow'"
|
||||||
|
:data-task-id="task.id"
|
||||||
|
@click="emit('open', task.id)"
|
||||||
|
>
|
||||||
|
<div class="flex items-start gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="mt-0.5 size-4 shrink-0 rounded-full border transition"
|
||||||
|
:class="
|
||||||
|
isDone ? 'border-emerald-500 bg-emerald-500' : 'border-slate-300 hover:border-emerald-500'
|
||||||
|
"
|
||||||
|
:aria-label="isDone ? '标记为未完成' : '标记为已完成'"
|
||||||
|
:aria-pressed="isDone"
|
||||||
|
@click.stop="emit('toggle', task.id)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<h3
|
||||||
|
class="min-w-0 flex-1 text-sm leading-snug wrap-break-word"
|
||||||
|
:class="isDone ? 'text-slate-400 line-through' : 'text-slate-800'"
|
||||||
|
>
|
||||||
|
{{ task.title }}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-2 flex flex-wrap items-center gap-1.5 pl-6">
|
||||||
|
<span class="rounded px-1.5 py-0.5 text-xs" :class="priorityClass">
|
||||||
|
{{ PRIORITY_LABELS[task.priority] }}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span
|
||||||
|
v-if="task.dueDate"
|
||||||
|
class="rounded px-1.5 py-0.5 text-xs"
|
||||||
|
:class="overdue ? 'bg-rose-100 font-medium text-rose-700' : 'bg-slate-100 text-slate-600'"
|
||||||
|
>
|
||||||
|
{{ overdue ? '⚠ ' : '' }}{{ formatDueDate(task.dueDate) }}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span
|
||||||
|
v-for="tag in visibleTags"
|
||||||
|
:key="tag"
|
||||||
|
class="rounded bg-sky-50 px-1.5 py-0.5 text-xs text-sky-700"
|
||||||
|
>
|
||||||
|
#{{ tag }}
|
||||||
|
</span>
|
||||||
|
<span v-if="hiddenTagCount > 0" class="text-xs text-slate-400">+{{ hiddenTagCount }}</span>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
import { mount } from '@vue/test-utils'
|
||||||
|
import type { VueWrapper } from '@vue/test-utils'
|
||||||
|
import { createPinia, setActivePinia } from 'pinia'
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
import { createMemoryTaskRepository } from '@/data/taskRepository'
|
||||||
|
import { useTaskStore } from '@/stores/tasks'
|
||||||
|
import { useToastStore } from '@/stores/toast'
|
||||||
|
|
||||||
|
import TaskDetailDrawer from './TaskDetailDrawer.vue'
|
||||||
|
|
||||||
|
function buttonByText(wrapper: VueWrapper, text: string) {
|
||||||
|
return wrapper.findAll('button').find((node) => node.text() === text)
|
||||||
|
}
|
||||||
|
|
||||||
|
function setup() {
|
||||||
|
const pinia = createPinia()
|
||||||
|
setActivePinia(pinia)
|
||||||
|
|
||||||
|
const store = useTaskStore()
|
||||||
|
store.init({ repository: createMemoryTaskRepository(), crossTab: false })
|
||||||
|
const toast = useToastStore()
|
||||||
|
|
||||||
|
const created = store.createTask({
|
||||||
|
title: '原标题',
|
||||||
|
description: '原描述',
|
||||||
|
priority: 1,
|
||||||
|
tags: ['设计'],
|
||||||
|
dueDate: '2026-12-01',
|
||||||
|
})
|
||||||
|
const taskId = created?.id ?? ''
|
||||||
|
const current = store.tasks.find((task) => task.id === taskId) ?? null
|
||||||
|
|
||||||
|
const wrapper = mount(TaskDetailDrawer, {
|
||||||
|
props: { task: current },
|
||||||
|
global: { plugins: [pinia], stubs: { teleport: true } },
|
||||||
|
attachTo: document.body,
|
||||||
|
})
|
||||||
|
|
||||||
|
return { store, toast, wrapper, taskId }
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('TaskDetailDrawer 回填', () => {
|
||||||
|
it('打开时回填任务各字段', () => {
|
||||||
|
const { wrapper } = setup()
|
||||||
|
expect((wrapper.find('input[aria-label="标题"]').element as HTMLInputElement).value).toBe(
|
||||||
|
'原标题',
|
||||||
|
)
|
||||||
|
expect(
|
||||||
|
(wrapper.find('textarea[aria-label="描述"]').element as HTMLTextAreaElement).value,
|
||||||
|
).toBe('原描述')
|
||||||
|
expect(wrapper.text()).toContain('#设计')
|
||||||
|
expect((wrapper.find('input[type="date"]').element as HTMLInputElement).value).toBe(
|
||||||
|
'2026-12-01',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('回填当前优先级(低)', () => {
|
||||||
|
const { wrapper } = setup()
|
||||||
|
const low = buttonByText(wrapper, '低')
|
||||||
|
expect(low?.attributes('aria-pressed')).toBe('true')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('task 为 null 时不渲染表单', () => {
|
||||||
|
const pinia = createPinia()
|
||||||
|
setActivePinia(pinia)
|
||||||
|
useTaskStore().init({ repository: createMemoryTaskRepository(), crossTab: false })
|
||||||
|
|
||||||
|
const wrapper = mount(TaskDetailDrawer, {
|
||||||
|
props: { task: null },
|
||||||
|
global: { plugins: [pinia], stubs: { teleport: true } },
|
||||||
|
})
|
||||||
|
expect(wrapper.find('form').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('TaskDetailDrawer 保存', () => {
|
||||||
|
it('保存后写入 store 并 emit close', async () => {
|
||||||
|
const { store, wrapper, taskId } = setup()
|
||||||
|
|
||||||
|
await wrapper.find('input[aria-label="标题"]').setValue('新标题')
|
||||||
|
await wrapper.find('textarea[aria-label="描述"]').setValue('新描述')
|
||||||
|
await buttonByText(wrapper, '高')?.trigger('click')
|
||||||
|
await wrapper.find('input[type="date"]').setValue('2026-11-11')
|
||||||
|
await wrapper.find('input[aria-label="添加标签"]').setValue('前端')
|
||||||
|
await wrapper.find('input[aria-label="添加标签"]').trigger('keydown', { key: 'Enter' })
|
||||||
|
await buttonByText(wrapper, '保存')?.trigger('click')
|
||||||
|
|
||||||
|
const task = store.tasks.find((item) => item.id === taskId)
|
||||||
|
expect(task?.title).toBe('新标题')
|
||||||
|
expect(task?.description).toBe('新描述')
|
||||||
|
expect(task?.priority).toBe(3)
|
||||||
|
expect(task?.dueDate).toBe('2026-11-11')
|
||||||
|
expect(task?.tags).toEqual(['设计', '前端'])
|
||||||
|
expect(wrapper.emitted('close')).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('标题为空时显示错误且不写入 store', async () => {
|
||||||
|
const { store, wrapper, taskId } = setup()
|
||||||
|
|
||||||
|
await wrapper.find('input[aria-label="标题"]').setValue(' ')
|
||||||
|
await buttonByText(wrapper, '保存')?.trigger('click')
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('标题不能为空')
|
||||||
|
expect(store.tasks.find((item) => item.id === taskId)?.title).toBe('原标题')
|
||||||
|
expect(wrapper.emitted('close')).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('重新输入标题后错误提示消失', async () => {
|
||||||
|
const { wrapper } = setup()
|
||||||
|
await wrapper.find('input[aria-label="标题"]').setValue('')
|
||||||
|
await buttonByText(wrapper, '保存')?.trigger('click')
|
||||||
|
expect(wrapper.text()).toContain('标题不能为空')
|
||||||
|
|
||||||
|
await wrapper.find('input[aria-label="标题"]').setValue('有内容了')
|
||||||
|
expect(wrapper.text()).not.toContain('标题不能为空')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('清空截止日期会写入 null', async () => {
|
||||||
|
const { store, wrapper, taskId } = setup()
|
||||||
|
await buttonByText(wrapper, '清除')?.trigger('click')
|
||||||
|
await buttonByText(wrapper, '保存')?.trigger('click')
|
||||||
|
expect(store.tasks.find((item) => item.id === taskId)?.dueDate).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('TaskDetailDrawer 取消', () => {
|
||||||
|
it('取消不产生脏数据', async () => {
|
||||||
|
const { store, wrapper, taskId } = setup()
|
||||||
|
|
||||||
|
await wrapper.find('input[aria-label="标题"]').setValue('改了一半')
|
||||||
|
await buttonByText(wrapper, '取消')?.trigger('click')
|
||||||
|
|
||||||
|
expect(store.tasks.find((item) => item.id === taskId)?.title).toBe('原标题')
|
||||||
|
expect(wrapper.emitted('close')).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('编辑后重新打开会回到最新数据,不残留草稿', async () => {
|
||||||
|
const { store, wrapper, taskId } = setup()
|
||||||
|
|
||||||
|
await wrapper.find('input[aria-label="标题"]').setValue('临时草稿')
|
||||||
|
await buttonByText(wrapper, '取消')?.trigger('click')
|
||||||
|
|
||||||
|
// 模拟真实的「关闭再打开」:BoardView 会把 editingId 置空再设回,
|
||||||
|
// 因此 prop 会经历 null -> task 的变化并触发 watcher
|
||||||
|
await wrapper.setProps({ task: null })
|
||||||
|
const task = store.tasks.find((item) => item.id === taskId) ?? null
|
||||||
|
await wrapper.setProps({ task })
|
||||||
|
|
||||||
|
expect((wrapper.find('input[aria-label="标题"]').element as HTMLInputElement).value).toBe(
|
||||||
|
'原标题',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('TaskDetailDrawer 删除与撤销', () => {
|
||||||
|
it('删除后任务消失并弹出可撤销提示', async () => {
|
||||||
|
const { store, toast, wrapper } = setup()
|
||||||
|
|
||||||
|
await buttonByText(wrapper, '删除')?.trigger('click')
|
||||||
|
|
||||||
|
expect(store.tasks).toHaveLength(0)
|
||||||
|
expect(wrapper.emitted('close')).toHaveLength(1)
|
||||||
|
expect(toast.toasts).toHaveLength(1)
|
||||||
|
expect(toast.toasts[0]?.action?.label).toBe('撤销')
|
||||||
|
expect(toast.toasts[0]?.message).toContain('原标题')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('点击撤销后任务连同原位置一起恢复', async () => {
|
||||||
|
const { store, toast, wrapper, taskId } = setup()
|
||||||
|
|
||||||
|
await buttonByText(wrapper, '删除')?.trigger('click')
|
||||||
|
const toastId = toast.toasts[0]?.id ?? ''
|
||||||
|
toast.runAction(toastId)
|
||||||
|
|
||||||
|
expect(store.tasks).toHaveLength(1)
|
||||||
|
expect(store.tasks[0]?.id).toBe(taskId)
|
||||||
|
expect(store.tasks[0]?.title).toBe('原标题')
|
||||||
|
expect(toast.toasts).toHaveLength(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch } from 'vue'
|
||||||
|
|
||||||
|
import { useTaskStore } from '@/stores/tasks'
|
||||||
|
import { useToastStore } from '@/stores/toast'
|
||||||
|
import { PRIORITY_LABELS, type Priority, type Task } from '@/types/task'
|
||||||
|
|
||||||
|
import DatePicker from './DatePicker.vue'
|
||||||
|
import SideDrawer from './SideDrawer.vue'
|
||||||
|
import TagInput from './TagInput.vue'
|
||||||
|
|
||||||
|
const props = defineProps<{ task: Task | null }>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{ close: [] }>()
|
||||||
|
|
||||||
|
const store = useTaskStore()
|
||||||
|
const toast = useToastStore()
|
||||||
|
|
||||||
|
const PRIORITIES: readonly Priority[] = [1, 2, 3]
|
||||||
|
|
||||||
|
const title = ref('')
|
||||||
|
const description = ref('')
|
||||||
|
const priority = ref<Priority>(2)
|
||||||
|
const dueDate = ref<string | null>(null)
|
||||||
|
const tags = ref<string[]>([])
|
||||||
|
const titleError = ref<string | null>(null)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 打开时把任务字段复制到本地草稿。
|
||||||
|
* 只有「保存」才写回 store,因此取消编辑不会产生脏数据。
|
||||||
|
*/
|
||||||
|
watch(
|
||||||
|
() => props.task,
|
||||||
|
(task) => {
|
||||||
|
title.value = task?.title ?? ''
|
||||||
|
description.value = task?.description ?? ''
|
||||||
|
priority.value = task?.priority ?? 2
|
||||||
|
dueDate.value = task?.dueDate ?? null
|
||||||
|
tags.value = task ? [...task.tags] : []
|
||||||
|
titleError.value = null
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
function save(): void {
|
||||||
|
const task = props.task
|
||||||
|
if (!task) return
|
||||||
|
|
||||||
|
const trimmed = title.value.trim()
|
||||||
|
if (!trimmed) {
|
||||||
|
titleError.value = '标题不能为空'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
store.updateTask(task.id, {
|
||||||
|
title: trimmed,
|
||||||
|
description: description.value.trim(),
|
||||||
|
priority: priority.value,
|
||||||
|
dueDate: dueDate.value,
|
||||||
|
tags: [...tags.value],
|
||||||
|
})
|
||||||
|
emit('close')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 删除不弹二次确认,改由 Toast 的撤销兜底(误删成本更低) */
|
||||||
|
function remove(): void {
|
||||||
|
const task = props.task
|
||||||
|
if (!task) return
|
||||||
|
|
||||||
|
const removed = store.removeTask(task.id)
|
||||||
|
emit('close')
|
||||||
|
if (!removed) return
|
||||||
|
|
||||||
|
toast.show({
|
||||||
|
message: `已删除「${removed.title}」`,
|
||||||
|
action: { label: '撤销', run: () => store.restoreTask(removed) },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<SideDrawer :open="task !== null" title="任务详情" @close="emit('close')">
|
||||||
|
<form v-if="task" class="flex flex-col gap-4" @submit.prevent="save">
|
||||||
|
<label class="flex flex-col gap-1.5">
|
||||||
|
<span class="text-sm font-medium text-slate-600">标题</span>
|
||||||
|
<input
|
||||||
|
v-model="title"
|
||||||
|
type="text"
|
||||||
|
class="rounded-md border border-slate-300 px-2.5 py-1.5 text-sm text-slate-700 outline-none focus:border-sky-400"
|
||||||
|
aria-label="标题"
|
||||||
|
@input="titleError = null"
|
||||||
|
/>
|
||||||
|
<span v-if="titleError" class="text-xs text-rose-600">{{ titleError }}</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="flex flex-col gap-1.5">
|
||||||
|
<span class="text-sm font-medium text-slate-600">描述</span>
|
||||||
|
<textarea
|
||||||
|
v-model="description"
|
||||||
|
rows="4"
|
||||||
|
class="resize-y rounded-md border border-slate-300 px-2.5 py-1.5 text-sm text-slate-700 outline-none focus:border-sky-400"
|
||||||
|
aria-label="描述"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<fieldset class="flex flex-col gap-1.5">
|
||||||
|
<legend class="mb-1.5 text-sm font-medium text-slate-600">优先级</legend>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button
|
||||||
|
v-for="value in PRIORITIES"
|
||||||
|
:key="value"
|
||||||
|
type="button"
|
||||||
|
class="rounded-md border px-3 py-1 text-sm transition"
|
||||||
|
:class="
|
||||||
|
priority === value
|
||||||
|
? 'border-sky-400 bg-sky-50 text-sky-700'
|
||||||
|
: 'border-slate-300 text-slate-600 hover:bg-slate-50'
|
||||||
|
"
|
||||||
|
:aria-pressed="priority === value"
|
||||||
|
@click="priority = value"
|
||||||
|
>
|
||||||
|
{{ PRIORITY_LABELS[value] }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-1.5">
|
||||||
|
<span class="text-sm font-medium text-slate-600">截止日期</span>
|
||||||
|
<DatePicker v-model="dueDate" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-1.5">
|
||||||
|
<span class="text-sm font-medium text-slate-600">标签</span>
|
||||||
|
<TagInput v-model="tags" />
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-md px-2.5 py-1.5 text-sm text-rose-600 transition hover:bg-rose-50"
|
||||||
|
@click="remove"
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-md border border-slate-300 px-3 py-1.5 text-sm text-slate-600 transition hover:bg-slate-50"
|
||||||
|
@click="emit('close')"
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-md bg-sky-600 px-3 py-1.5 text-sm font-medium text-white transition hover:bg-sky-700"
|
||||||
|
@click="save"
|
||||||
|
>
|
||||||
|
保存
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</SideDrawer>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { mount } from '@vue/test-utils'
|
||||||
|
import { createPinia, setActivePinia } from 'pinia'
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
import { useToastStore } from '@/stores/toast'
|
||||||
|
|
||||||
|
import ToastHost from './ToastHost.vue'
|
||||||
|
|
||||||
|
function setup() {
|
||||||
|
const pinia = createPinia()
|
||||||
|
setActivePinia(pinia)
|
||||||
|
const store = useToastStore()
|
||||||
|
const wrapper = mount(ToastHost, { global: { plugins: [pinia] } })
|
||||||
|
return { store, wrapper }
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ToastHost', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
setActivePinia(createPinia())
|
||||||
|
})
|
||||||
|
|
||||||
|
it('无提示时不渲染任何内容', () => {
|
||||||
|
const { wrapper } = setup()
|
||||||
|
expect(wrapper.findAll('[role="status"]')).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('渲染提示文案', async () => {
|
||||||
|
const { store, wrapper } = setup()
|
||||||
|
store.show({ message: '已保存', duration: 0 })
|
||||||
|
await wrapper.vm.$nextTick()
|
||||||
|
expect(wrapper.text()).toContain('已保存')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('点击关闭按钮移除提示', async () => {
|
||||||
|
const { store, wrapper } = setup()
|
||||||
|
store.show({ message: 'A', duration: 0 })
|
||||||
|
await wrapper.vm.$nextTick()
|
||||||
|
|
||||||
|
await wrapper.find('button[aria-label="关闭提示"]').trigger('click')
|
||||||
|
expect(store.toasts).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('带动作时渲染动作按钮并执行回调', async () => {
|
||||||
|
const { store, wrapper } = setup()
|
||||||
|
const run = vi.fn()
|
||||||
|
store.show({ message: '已删除', action: { label: '撤销', run }, duration: 0 })
|
||||||
|
await wrapper.vm.$nextTick()
|
||||||
|
|
||||||
|
const actionButton = wrapper
|
||||||
|
.findAll('button')
|
||||||
|
.find((node) => node.text() === '撤销')
|
||||||
|
expect(actionButton).toBeDefined()
|
||||||
|
|
||||||
|
await actionButton?.trigger('click')
|
||||||
|
expect(run).toHaveBeenCalledTimes(1)
|
||||||
|
expect(store.toasts).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('无动作时不渲染动作按钮', async () => {
|
||||||
|
const { store, wrapper } = setup()
|
||||||
|
store.show({ message: '普通提示', duration: 0 })
|
||||||
|
await wrapper.vm.$nextTick()
|
||||||
|
|
||||||
|
expect(wrapper.findAll('button')).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('提示容器带 role=status 便于读屏播报', async () => {
|
||||||
|
const { store, wrapper } = setup()
|
||||||
|
store.show({ message: 'A', duration: 0 })
|
||||||
|
await wrapper.vm.$nextTick()
|
||||||
|
expect(wrapper.find('[role="status"]').exists()).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { storeToRefs } from 'pinia'
|
||||||
|
|
||||||
|
import { useToastStore } from '@/stores/toast'
|
||||||
|
|
||||||
|
const store = useToastStore()
|
||||||
|
const { toasts } = storeToRefs(store)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="pointer-events-none fixed inset-x-0 bottom-4 z-50 flex flex-col items-center gap-2 px-4"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-for="toast in toasts"
|
||||||
|
:key="toast.id"
|
||||||
|
role="status"
|
||||||
|
class="pointer-events-auto flex w-full max-w-sm items-center gap-3 rounded-md bg-slate-800 px-3 py-2 text-sm text-white shadow-lg"
|
||||||
|
>
|
||||||
|
<span class="min-w-0 flex-1 wrap-break-word">{{ toast.message }}</span>
|
||||||
|
|
||||||
|
<button
|
||||||
|
v-if="toast.action"
|
||||||
|
type="button"
|
||||||
|
class="shrink-0 rounded px-2 py-0.5 text-sm font-medium text-sky-300 transition hover:bg-white/10"
|
||||||
|
@click="store.runAction(toast.id)"
|
||||||
|
>
|
||||||
|
{{ toast.action.label }}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="shrink-0 rounded px-1.5 text-slate-400 transition hover:text-white"
|
||||||
|
aria-label="关闭提示"
|
||||||
|
@click="store.dismiss(toast.id)"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
import { isEditableTarget, matchShortcut, type ShortcutEventLike } from './useKeyboardShortcuts'
|
||||||
|
|
||||||
|
function makeEvent(overrides: Partial<ShortcutEventLike> = {}): ShortcutEventLike {
|
||||||
|
return {
|
||||||
|
key: 'n',
|
||||||
|
target: document.body,
|
||||||
|
defaultPrevented: false,
|
||||||
|
ctrlKey: false,
|
||||||
|
metaKey: false,
|
||||||
|
altKey: false,
|
||||||
|
...overrides,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeElement(tag: string, contentEditable = false): HTMLElement {
|
||||||
|
const element = document.createElement(tag)
|
||||||
|
// 用 setAttribute 而非 property:jsdom 不会把 contentEditable 属性反射到 attribute
|
||||||
|
if (contentEditable) element.setAttribute('contenteditable', 'true')
|
||||||
|
return element
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('isEditableTarget', () => {
|
||||||
|
it('识别输入类元素', () => {
|
||||||
|
expect(isEditableTarget(makeElement('input'))).toBe(true)
|
||||||
|
expect(isEditableTarget(makeElement('textarea'))).toBe(true)
|
||||||
|
expect(isEditableTarget(makeElement('select'))).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('识别 contenteditable', () => {
|
||||||
|
expect(isEditableTarget(makeElement('div', true))).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('普通元素不算可编辑', () => {
|
||||||
|
expect(isEditableTarget(makeElement('div'))).toBe(false)
|
||||||
|
expect(isEditableTarget(document.body)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('null 不算可编辑', () => {
|
||||||
|
expect(isEditableTarget(null)).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('matchShortcut', () => {
|
||||||
|
it('命中时返回处理函数', () => {
|
||||||
|
const handler = vi.fn()
|
||||||
|
expect(matchShortcut(makeEvent({ key: 'n' }), { n: handler })).toBe(handler)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('按键大小写不敏感', () => {
|
||||||
|
const handler = vi.fn()
|
||||||
|
expect(matchShortcut(makeEvent({ key: 'N' }), { n: handler })).toBe(handler)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('支持 / 与 ? 这类符号键', () => {
|
||||||
|
const slash = vi.fn()
|
||||||
|
const question = vi.fn()
|
||||||
|
const shortcuts = { '/': slash, '?': question }
|
||||||
|
expect(matchShortcut(makeEvent({ key: '/' }), shortcuts)).toBe(slash)
|
||||||
|
expect(matchShortcut(makeEvent({ key: '?' }), shortcuts)).toBe(question)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('输入框内不触发', () => {
|
||||||
|
const handler = vi.fn()
|
||||||
|
expect(matchShortcut(makeEvent({ target: makeElement('input') }), { n: handler })).toBeNull()
|
||||||
|
expect(matchShortcut(makeEvent({ target: makeElement('textarea') }), { n: handler })).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('带 Ctrl / Cmd / Alt 修饰键时不触发', () => {
|
||||||
|
const handler = vi.fn()
|
||||||
|
expect(matchShortcut(makeEvent({ ctrlKey: true }), { n: handler })).toBeNull()
|
||||||
|
expect(matchShortcut(makeEvent({ metaKey: true }), { n: handler })).toBeNull()
|
||||||
|
expect(matchShortcut(makeEvent({ altKey: true }), { n: handler })).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('已被处理的事件不重复触发', () => {
|
||||||
|
const handler = vi.fn()
|
||||||
|
expect(matchShortcut(makeEvent({ defaultPrevented: true }), { n: handler })).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('未注册的按键返回 null', () => {
|
||||||
|
expect(matchShortcut(makeEvent({ key: 'z' }), { n: vi.fn() })).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
/**
|
||||||
|
* 全局键盘快捷键(T16)
|
||||||
|
*
|
||||||
|
* 关键约束:**在输入框内打字时不劫持按键**。
|
||||||
|
* 否则用户在搜索框里输入 "n" 会触发「新增任务」。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { onBeforeUnmount, onMounted } from 'vue'
|
||||||
|
|
||||||
|
export type ShortcutMap = Record<string, () => void>
|
||||||
|
|
||||||
|
/** 事件是否发生在可编辑元素内 */
|
||||||
|
export function isEditableTarget(target: EventTarget | null): boolean {
|
||||||
|
if (typeof HTMLElement === 'undefined' || !(target instanceof HTMLElement)) return false
|
||||||
|
|
||||||
|
const tag = target.tagName
|
||||||
|
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return true
|
||||||
|
|
||||||
|
// jsdom 未实现 isContentEditable,因此同时检查属性,保证行为可测且与浏览器一致
|
||||||
|
if (target.isContentEditable === true) return true
|
||||||
|
const attribute = target.getAttribute('contenteditable')
|
||||||
|
return attribute === '' || attribute === 'true'
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ShortcutEventLike {
|
||||||
|
key: string
|
||||||
|
target: EventTarget | null
|
||||||
|
defaultPrevented: boolean
|
||||||
|
ctrlKey: boolean
|
||||||
|
metaKey: boolean
|
||||||
|
altKey: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析出应执行的快捷键处理函数;不满足条件时返回 null。
|
||||||
|
* 独立成纯函数便于测试。
|
||||||
|
*/
|
||||||
|
export function matchShortcut(
|
||||||
|
event: ShortcutEventLike,
|
||||||
|
shortcuts: ShortcutMap,
|
||||||
|
): (() => void) | null {
|
||||||
|
if (event.defaultPrevented) return null
|
||||||
|
// 带修饰键的组合交给浏览器(如 Ctrl+F 查找、Cmd+R 刷新)
|
||||||
|
if (event.ctrlKey || event.metaKey || event.altKey) return null
|
||||||
|
if (isEditableTarget(event.target)) return null
|
||||||
|
|
||||||
|
return shortcuts[event.key.toLowerCase()] ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useKeyboardShortcuts(shortcuts: ShortcutMap): void {
|
||||||
|
function onKeydown(event: KeyboardEvent): void {
|
||||||
|
const handler = matchShortcut(event, shortcuts)
|
||||||
|
if (!handler) return
|
||||||
|
event.preventDefault()
|
||||||
|
handler()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => document.addEventListener('keydown', onKeydown))
|
||||||
|
onBeforeUnmount(() => document.removeEventListener('keydown', onKeydown))
|
||||||
|
}
|
||||||
@@ -0,0 +1,262 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
import {
|
||||||
|
DataValidationError,
|
||||||
|
SCHEMA_VERSION,
|
||||||
|
coerceTask,
|
||||||
|
coerceTaskList,
|
||||||
|
isValidDateString,
|
||||||
|
parseImportJson,
|
||||||
|
parsePersistedState,
|
||||||
|
serializeState,
|
||||||
|
validateImportPayload,
|
||||||
|
} from './schema'
|
||||||
|
|
||||||
|
function validTask(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
||||||
|
return {
|
||||||
|
id: 'task-1',
|
||||||
|
title: '写方案',
|
||||||
|
description: '细节',
|
||||||
|
status: 'todo',
|
||||||
|
priority: 2,
|
||||||
|
tags: ['设计'],
|
||||||
|
dueDate: '2026-10-20',
|
||||||
|
sortOrder: 1024,
|
||||||
|
createdAt: '2026-10-18T00:00:00.000Z',
|
||||||
|
updatedAt: '2026-10-18T00:00:00.000Z',
|
||||||
|
completedAt: null,
|
||||||
|
archivedAt: null,
|
||||||
|
...overrides,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('isValidDateString', () => {
|
||||||
|
it('接受合法日期', () => {
|
||||||
|
expect(isValidDateString('2026-10-18')).toBe(true)
|
||||||
|
expect(isValidDateString('2024-02-29')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('拒绝格式不对的值', () => {
|
||||||
|
expect(isValidDateString('2026-1-1')).toBe(false)
|
||||||
|
expect(isValidDateString('2026/10/18')).toBe(false)
|
||||||
|
expect(isValidDateString('')).toBe(false)
|
||||||
|
expect(isValidDateString(20261018)).toBe(false)
|
||||||
|
expect(isValidDateString(null)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('拒绝「格式正确但日期不存在」的值', () => {
|
||||||
|
expect(isValidDateString('2026-02-31')).toBe(false)
|
||||||
|
expect(isValidDateString('2026-13-01')).toBe(false)
|
||||||
|
expect(isValidDateString('2025-02-29')).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('coerceTask', () => {
|
||||||
|
it('接受完整合法任务', () => {
|
||||||
|
expect(coerceTask(validTask())).toEqual(validTask())
|
||||||
|
})
|
||||||
|
|
||||||
|
it('缺少 id 或 title 时返回 null', () => {
|
||||||
|
expect(coerceTask(validTask({ id: undefined }))).toBeNull()
|
||||||
|
expect(coerceTask(validTask({ title: ' ' }))).toBeNull()
|
||||||
|
expect(coerceTask(validTask({ title: 123 }))).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('status 或 priority 非法时返回 null', () => {
|
||||||
|
expect(coerceTask(validTask({ status: 'archived' }))).toBeNull()
|
||||||
|
expect(coerceTask(validTask({ priority: 9 }))).toBeNull()
|
||||||
|
expect(coerceTask(validTask({ priority: '2' }))).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('非对象一律返回 null', () => {
|
||||||
|
expect(coerceTask(null)).toBeNull()
|
||||||
|
expect(coerceTask('task')).toBeNull()
|
||||||
|
expect(coerceTask([])).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('可选项缺失时补默认值', () => {
|
||||||
|
const result = coerceTask({
|
||||||
|
id: 'a',
|
||||||
|
title: '只有必填项',
|
||||||
|
status: 'doing',
|
||||||
|
priority: 1,
|
||||||
|
})
|
||||||
|
expect(result).not.toBeNull()
|
||||||
|
expect(result?.description).toBe('')
|
||||||
|
expect(result?.tags).toEqual([])
|
||||||
|
expect(result?.dueDate).toBeNull()
|
||||||
|
expect(result?.sortOrder).toBe(0)
|
||||||
|
expect(result?.completedAt).toBeNull()
|
||||||
|
expect(result?.createdAt).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('非法 dueDate 收敛为 null,非法 sortOrder 收敛为 0', () => {
|
||||||
|
const result = coerceTask(validTask({ dueDate: '2026-02-31', sortOrder: 'abc' }))
|
||||||
|
expect(result?.dueDate).toBeNull()
|
||||||
|
expect(result?.sortOrder).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('tags 过滤掉非字符串与空白项', () => {
|
||||||
|
const result = coerceTask(validTask({ tags: ['ok', 1, null, ' ', 'ok2'] }))
|
||||||
|
expect(result?.tags).toEqual(['ok', 'ok2'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('非法 archivedAt 收敛为 null', () => {
|
||||||
|
expect(coerceTask(validTask({ archivedAt: 123 }))?.archivedAt).toBeNull()
|
||||||
|
expect(coerceTask(validTask({ archivedAt: null }))?.archivedAt).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('保留合法的 archivedAt', () => {
|
||||||
|
expect(coerceTask(validTask({ archivedAt: '2026-10-01T00:00:00.000Z' }))?.archivedAt).toBe(
|
||||||
|
'2026-10-01T00:00:00.000Z',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('coerceTaskList', () => {
|
||||||
|
it('跳过损坏条目但保留合法条目', () => {
|
||||||
|
const list = coerceTaskList([validTask(), { id: 'broken' }, validTask({ id: 'task-2' })])
|
||||||
|
expect(list.map((task) => task.id)).toEqual(['task-1', 'task-2'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('非数组返回空数组', () => {
|
||||||
|
expect(coerceTaskList(null)).toEqual([])
|
||||||
|
expect(coerceTaskList({})).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('parsePersistedState', () => {
|
||||||
|
it('空值返回空看板', () => {
|
||||||
|
expect(parsePersistedState(null)).toEqual({ schemaVersion: SCHEMA_VERSION, tasks: [] })
|
||||||
|
expect(parsePersistedState(' ')).toEqual({ schemaVersion: SCHEMA_VERSION, tasks: [] })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('非 JSON 抛 DataValidationError', () => {
|
||||||
|
expect(() => parsePersistedState('{oops')).toThrow(DataValidationError)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('非对象抛 DataValidationError', () => {
|
||||||
|
expect(() => parsePersistedState('[1,2,3]')).toThrow(DataValidationError)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('缺少 schemaVersion 的旧数据迁移为当前版本', () => {
|
||||||
|
const legacy = JSON.stringify({ tasks: [validTask()] })
|
||||||
|
const state = parsePersistedState(legacy)
|
||||||
|
expect(state.schemaVersion).toBe(SCHEMA_VERSION)
|
||||||
|
expect(state.tasks).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('版本高于当前支持时抛错(不静默降级)', () => {
|
||||||
|
const future = JSON.stringify({ schemaVersion: SCHEMA_VERSION + 1, tasks: [] })
|
||||||
|
expect(() => parsePersistedState(future)).toThrow(/高于当前支持/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('损坏条目被跳过,其余数据仍可用', () => {
|
||||||
|
const mixed = JSON.stringify({
|
||||||
|
schemaVersion: SCHEMA_VERSION,
|
||||||
|
tasks: [validTask(), { id: 'x', title: '' }],
|
||||||
|
})
|
||||||
|
expect(parsePersistedState(mixed).tasks).toHaveLength(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('版本迁移', () => {
|
||||||
|
it('v1 数据迁移到 v2 时补上 archivedAt,其余字段不变', () => {
|
||||||
|
const v1 = JSON.stringify({
|
||||||
|
schemaVersion: 1,
|
||||||
|
tasks: [
|
||||||
|
{
|
||||||
|
id: 'legacy',
|
||||||
|
title: '旧任务',
|
||||||
|
status: 'done',
|
||||||
|
priority: 3,
|
||||||
|
sortOrder: 1024,
|
||||||
|
createdAt: '2026-01-01T00:00:00.000Z',
|
||||||
|
updatedAt: '2026-01-01T00:00:00.000Z',
|
||||||
|
completedAt: '2026-01-02T00:00:00.000Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
const state = parsePersistedState(v1)
|
||||||
|
|
||||||
|
expect(state.schemaVersion).toBe(SCHEMA_VERSION)
|
||||||
|
expect(state.tasks[0]?.archivedAt).toBeNull()
|
||||||
|
expect(state.tasks[0]?.title).toBe('旧任务')
|
||||||
|
expect(state.tasks[0]?.priority).toBe(3)
|
||||||
|
expect(state.tasks[0]?.completedAt).toBe('2026-01-02T00:00:00.000Z')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('无版本号的最早期数据能一路迁移到当前版本', () => {
|
||||||
|
const legacy = JSON.stringify({
|
||||||
|
tasks: [{ id: 'a', title: '最早的任务', status: 'todo', priority: 2 }],
|
||||||
|
})
|
||||||
|
|
||||||
|
const state = parsePersistedState(legacy)
|
||||||
|
|
||||||
|
expect(state.schemaVersion).toBe(SCHEMA_VERSION)
|
||||||
|
expect(state.tasks[0]?.archivedAt).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('归档状态在序列化往返后保留', () => {
|
||||||
|
const tasks = coerceTaskList([validTask({ archivedAt: '2026-10-01T00:00:00.000Z' })])
|
||||||
|
const roundTrip = parsePersistedState(serializeState(tasks))
|
||||||
|
expect(roundTrip.tasks[0]?.archivedAt).toBe('2026-10-01T00:00:00.000Z')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('多版本迁移对多条任务都生效', () => {
|
||||||
|
const v1 = JSON.stringify({
|
||||||
|
schemaVersion: 1,
|
||||||
|
tasks: [
|
||||||
|
{ id: 'a', title: 'A', status: 'todo', priority: 1 },
|
||||||
|
{ id: 'b', title: 'B', status: 'done', priority: 2 },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
const state = parsePersistedState(v1)
|
||||||
|
expect(state.tasks.map((task) => task.archivedAt)).toEqual([null, null])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('序列化往返', () => {
|
||||||
|
it('serializeState 与 parsePersistedState 互逆', () => {
|
||||||
|
const tasks = coerceTaskList([validTask(), validTask({ id: 'task-2', status: 'done' })])
|
||||||
|
const roundTrip = parsePersistedState(serializeState(tasks))
|
||||||
|
expect(roundTrip.tasks).toEqual(tasks)
|
||||||
|
expect(roundTrip.schemaVersion).toBe(SCHEMA_VERSION)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('pretty 模式仍可解析', () => {
|
||||||
|
const tasks = coerceTaskList([validTask()])
|
||||||
|
const pretty = serializeState(tasks, true)
|
||||||
|
expect(pretty).toContain('\n')
|
||||||
|
expect(parsePersistedState(pretty).tasks).toEqual(tasks)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('导入校验(严格)', () => {
|
||||||
|
it('任何一条任务不合法即整体拒绝', () => {
|
||||||
|
expect(() => validateImportPayload({ tasks: [validTask(), { id: 'broken' }] })).toThrow(
|
||||||
|
/第 2 条任务格式不合法/,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('缺少 tasks 数组时抛错', () => {
|
||||||
|
expect(() => validateImportPayload({ schemaVersion: 1 })).toThrow(/缺少 tasks 数组/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('非对象时抛错', () => {
|
||||||
|
expect(() => validateImportPayload([])).toThrow(/必须是一个 JSON 对象/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('版本过高时拒绝导入', () => {
|
||||||
|
expect(() => validateImportPayload({ schemaVersion: 99, tasks: [] })).toThrow(/高于当前支持/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('允许省略 schemaVersion', () => {
|
||||||
|
expect(validateImportPayload({ tasks: [validTask()] })).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('parseImportJson 对非法 JSON 给出明确错误', () => {
|
||||||
|
expect(() => parseImportJson('not json')).toThrow(/不是合法的 JSON/)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
/**
|
||||||
|
* 持久化数据结构与迁移(T04)
|
||||||
|
*
|
||||||
|
* 职责:
|
||||||
|
* - 定义落盘结构 PersistedState(含 schemaVersion)
|
||||||
|
* - 解析与迁移:parsePersistedState(宽容,用于读取本地数据)
|
||||||
|
* - 导入校验:parseImportJson(严格,用于导入外部文件)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { isColumnId, isPriority, type Task } from '@/types/task'
|
||||||
|
|
||||||
|
export const SCHEMA_VERSION = 2
|
||||||
|
|
||||||
|
/** 主数据在 localStorage 中的键名 */
|
||||||
|
export const STORAGE_KEY = 'kanban.tasks'
|
||||||
|
|
||||||
|
/** 自动快照键前缀(T14) */
|
||||||
|
export const SNAPSHOT_KEY_PREFIX = 'kanban.snapshot.'
|
||||||
|
|
||||||
|
export interface PersistedState {
|
||||||
|
schemaVersion: number
|
||||||
|
tasks: Task[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 数据不符合预期结构时抛出,调用方据此提示用户而不是白屏 */
|
||||||
|
export class DataValidationError extends Error {
|
||||||
|
constructor(message: string) {
|
||||||
|
super(message)
|
||||||
|
this.name = 'DataValidationError'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/
|
||||||
|
|
||||||
|
/** 校验 YYYY-MM-DD,并排除 2026-02-31 这类「格式对但日期不存在」的值 */
|
||||||
|
export function isValidDateString(value: unknown): value is string {
|
||||||
|
if (typeof value !== 'string' || !DATE_PATTERN.test(value)) return false
|
||||||
|
const parsed = new Date(`${value}T00:00:00.000Z`)
|
||||||
|
return !Number.isNaN(parsed.getTime()) && parsed.toISOString().slice(0, 10) === value
|
||||||
|
}
|
||||||
|
|
||||||
|
function readString(source: Record<string, unknown>, key: string): string | null {
|
||||||
|
const value = source[key]
|
||||||
|
return typeof value === 'string' ? value : null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将未知值收敛为合法 Task;无法收敛时返回 null。
|
||||||
|
* 单个任务损坏不应导致整份数据不可用,因此这里采用「跳过」而非「抛错」。
|
||||||
|
*/
|
||||||
|
export function coerceTask(value: unknown): Task | null {
|
||||||
|
if (!isRecord(value)) return null
|
||||||
|
|
||||||
|
const id = readString(value, 'id')
|
||||||
|
const title = readString(value, 'title')
|
||||||
|
if (!id || !title || title.trim() === '') return null
|
||||||
|
|
||||||
|
const status = value.status
|
||||||
|
if (!isColumnId(status)) return null
|
||||||
|
|
||||||
|
const priority = value.priority
|
||||||
|
if (!isPriority(priority)) return null
|
||||||
|
|
||||||
|
const rawTags = Array.isArray(value.tags) ? value.tags : []
|
||||||
|
const tags = rawTags.filter(
|
||||||
|
(tag): tag is string => typeof tag === 'string' && tag.trim() !== '',
|
||||||
|
)
|
||||||
|
|
||||||
|
const dueDate = value.dueDate
|
||||||
|
const sortOrder = value.sortOrder
|
||||||
|
const createdAt = readString(value, 'createdAt') ?? new Date().toISOString()
|
||||||
|
const updatedAt = readString(value, 'updatedAt') ?? createdAt
|
||||||
|
const completedAt = readString(value, 'completedAt')
|
||||||
|
const archivedAt = readString(value, 'archivedAt')
|
||||||
|
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
title,
|
||||||
|
description: readString(value, 'description') ?? '',
|
||||||
|
status,
|
||||||
|
priority,
|
||||||
|
tags,
|
||||||
|
dueDate: isValidDateString(dueDate) ? dueDate : null,
|
||||||
|
sortOrder: typeof sortOrder === 'number' && Number.isFinite(sortOrder) ? sortOrder : 0,
|
||||||
|
createdAt,
|
||||||
|
updatedAt,
|
||||||
|
completedAt,
|
||||||
|
archivedAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function coerceTaskList(value: unknown): Task[] {
|
||||||
|
if (!Array.isArray(value)) return []
|
||||||
|
const tasks: Task[] = []
|
||||||
|
for (const item of value) {
|
||||||
|
const task = coerceTask(item)
|
||||||
|
if (task) tasks.push(task)
|
||||||
|
}
|
||||||
|
return tasks
|
||||||
|
}
|
||||||
|
|
||||||
|
type Migration = (state: PersistedState) => PersistedState
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 版本迁移表,键为「迁移前的版本号」。
|
||||||
|
* - v0:早期数据没有 schemaVersion 字段,任务结构与 v1 一致
|
||||||
|
* - v1 -> v2:新增 archivedAt 字段(归档功能)
|
||||||
|
* 未来新增字段时,在此登记 n -> n+1 的转换,并同步提升 SCHEMA_VERSION。
|
||||||
|
*/
|
||||||
|
const MIGRATIONS: Record<number, Migration> = {
|
||||||
|
0: (state) => ({ ...state, schemaVersion: 1 }),
|
||||||
|
1: (state) => ({
|
||||||
|
schemaVersion: 2,
|
||||||
|
tasks: state.tasks.map((task) => ({ ...task, archivedAt: null })),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析本地持久化数据(宽容策略)。
|
||||||
|
* 空值返回空看板;结构性错误抛出 DataValidationError。
|
||||||
|
*/
|
||||||
|
export function parsePersistedState(raw: string | null): PersistedState {
|
||||||
|
if (raw === null || raw.trim() === '') {
|
||||||
|
return { schemaVersion: SCHEMA_VERSION, tasks: [] }
|
||||||
|
}
|
||||||
|
|
||||||
|
let parsed: unknown
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(raw)
|
||||||
|
} catch {
|
||||||
|
throw new DataValidationError('本地数据不是合法的 JSON')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isRecord(parsed)) {
|
||||||
|
throw new DataValidationError('本地数据结构异常:期望一个对象')
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawVersion = parsed.schemaVersion
|
||||||
|
const startVersion =
|
||||||
|
typeof rawVersion === 'number' && Number.isInteger(rawVersion) && rawVersion >= 0
|
||||||
|
? rawVersion
|
||||||
|
: 0
|
||||||
|
|
||||||
|
if (startVersion > SCHEMA_VERSION) {
|
||||||
|
throw new DataValidationError(
|
||||||
|
`数据版本 ${startVersion} 高于当前支持的 ${SCHEMA_VERSION},请升级应用后再打开`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
let state: PersistedState = {
|
||||||
|
schemaVersion: startVersion,
|
||||||
|
tasks: coerceTaskList(parsed.tasks),
|
||||||
|
}
|
||||||
|
|
||||||
|
while (state.schemaVersion < SCHEMA_VERSION) {
|
||||||
|
const migration = MIGRATIONS[state.schemaVersion]
|
||||||
|
if (!migration) {
|
||||||
|
throw new DataValidationError(
|
||||||
|
`缺少从版本 ${state.schemaVersion} 到 ${state.schemaVersion + 1} 的迁移逻辑`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
state = migration(state)
|
||||||
|
}
|
||||||
|
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 严格校验导入内容(T14)。任何一条任务不合法即整体拒绝,
|
||||||
|
* 以保证「非法文件不破坏现有数据」。
|
||||||
|
*/
|
||||||
|
export function validateImportPayload(value: unknown): Task[] {
|
||||||
|
if (!isRecord(value)) {
|
||||||
|
throw new DataValidationError('导入内容必须是一个 JSON 对象')
|
||||||
|
}
|
||||||
|
|
||||||
|
const version = value.schemaVersion
|
||||||
|
if (version !== undefined) {
|
||||||
|
if (typeof version !== 'number' || !Number.isInteger(version) || version < 0) {
|
||||||
|
throw new DataValidationError('schemaVersion 字段不合法')
|
||||||
|
}
|
||||||
|
if (version > SCHEMA_VERSION) {
|
||||||
|
throw new DataValidationError(
|
||||||
|
`导入文件的版本 ${version} 高于当前支持的 ${SCHEMA_VERSION},无法导入`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Array.isArray(value.tasks)) {
|
||||||
|
throw new DataValidationError('导入内容缺少 tasks 数组')
|
||||||
|
}
|
||||||
|
|
||||||
|
const tasks: Task[] = []
|
||||||
|
value.tasks.forEach((item, index) => {
|
||||||
|
const task = coerceTask(item)
|
||||||
|
if (!task) {
|
||||||
|
throw new DataValidationError(`第 ${index + 1} 条任务格式不合法,已取消导入`)
|
||||||
|
}
|
||||||
|
tasks.push(task)
|
||||||
|
})
|
||||||
|
return tasks
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseImportJson(raw: string): Task[] {
|
||||||
|
let parsed: unknown
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(raw)
|
||||||
|
} catch {
|
||||||
|
throw new DataValidationError('导入文件不是合法的 JSON')
|
||||||
|
}
|
||||||
|
return validateImportPayload(parsed)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function serializeState(tasks: readonly Task[], pretty = false): string {
|
||||||
|
const state: PersistedState = { schemaVersion: SCHEMA_VERSION, tasks: [...tasks] }
|
||||||
|
return pretty ? JSON.stringify(state, null, 2) : JSON.stringify(state)
|
||||||
|
}
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
import type { Task } from '@/types/task'
|
||||||
|
|
||||||
|
import { DataValidationError, SNAPSHOT_KEY_PREFIX } from './schema'
|
||||||
|
import {
|
||||||
|
MAX_SNAPSHOTS,
|
||||||
|
SNAPSHOT_INDEX_KEY,
|
||||||
|
listSnapshots,
|
||||||
|
readSnapshot,
|
||||||
|
writeSnapshot,
|
||||||
|
} from './snapshot'
|
||||||
|
import type { StorageLike } from './storage'
|
||||||
|
|
||||||
|
interface FakeStorage extends StorageLike {
|
||||||
|
data: Record<string, string>
|
||||||
|
}
|
||||||
|
|
||||||
|
function createFakeStorage(initial: Record<string, string> = {}): FakeStorage {
|
||||||
|
const data: Record<string, string> = { ...initial }
|
||||||
|
return {
|
||||||
|
data,
|
||||||
|
getItem: (key) => (key in data ? (data[key] ?? null) : null),
|
||||||
|
setItem: (key, value) => {
|
||||||
|
data[key] = value
|
||||||
|
},
|
||||||
|
removeItem: (key) => {
|
||||||
|
delete data[key]
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeTask(id: string): Task {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
title: `任务 ${id}`,
|
||||||
|
description: '',
|
||||||
|
status: 'todo',
|
||||||
|
priority: 2,
|
||||||
|
tags: [],
|
||||||
|
dueDate: null,
|
||||||
|
sortOrder: 1024,
|
||||||
|
createdAt: '2026-10-18T00:00:00.000Z',
|
||||||
|
updatedAt: '2026-10-18T00:00:00.000Z',
|
||||||
|
completedAt: null,
|
||||||
|
archivedAt: null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 依次生成互不相同的时间点,避免同毫秒导致键名冲突 */
|
||||||
|
function at(minute: number): Date {
|
||||||
|
return new Date(Date.UTC(2026, 9, 18, 10, minute, 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
function snapshotKeys(storage: FakeStorage): string[] {
|
||||||
|
return Object.keys(storage.data).filter(
|
||||||
|
(key) => key.startsWith(SNAPSHOT_KEY_PREFIX) && key !== SNAPSHOT_INDEX_KEY,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('listSnapshots', () => {
|
||||||
|
it('空存储返回空数组', () => {
|
||||||
|
expect(listSnapshots(createFakeStorage())).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('索引损坏时返回空数组而不是抛错', () => {
|
||||||
|
const storage = createFakeStorage({ [SNAPSHOT_INDEX_KEY]: '{broken' })
|
||||||
|
expect(listSnapshots(storage)).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('索引不是数组时返回空数组', () => {
|
||||||
|
const storage = createFakeStorage({ [SNAPSHOT_INDEX_KEY]: '{"a":1}' })
|
||||||
|
expect(listSnapshots(storage)).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('跳过内容已丢失的条目', () => {
|
||||||
|
const storage = createFakeStorage({
|
||||||
|
[SNAPSHOT_INDEX_KEY]: JSON.stringify([
|
||||||
|
{ key: `${SNAPSHOT_KEY_PREFIX}missing`, savedAt: '2026-10-18T10:00:00.000Z' },
|
||||||
|
]),
|
||||||
|
})
|
||||||
|
expect(listSnapshots(storage)).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('writeSnapshot', () => {
|
||||||
|
it('写入后可以列出,且返回快照信息', () => {
|
||||||
|
const storage = createFakeStorage()
|
||||||
|
const info = writeSnapshot(storage, [makeTask('a')], at(0))
|
||||||
|
|
||||||
|
expect(info).not.toBeNull()
|
||||||
|
expect(info?.savedAt).toBe(at(0).toISOString())
|
||||||
|
expect(listSnapshots(storage)).toEqual([info])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('最新的排在最前', () => {
|
||||||
|
const storage = createFakeStorage()
|
||||||
|
writeSnapshot(storage, [makeTask('a')], at(0))
|
||||||
|
writeSnapshot(storage, [makeTask('b')], at(1))
|
||||||
|
writeSnapshot(storage, [makeTask('c')], at(2))
|
||||||
|
|
||||||
|
const savedAtList = listSnapshots(storage).map((info) => info.savedAt)
|
||||||
|
expect(savedAtList).toEqual([at(2).toISOString(), at(1).toISOString(), at(0).toISOString()])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('最多保留 MAX_SNAPSHOTS 份,超出的数据也被删除', () => {
|
||||||
|
const storage = createFakeStorage()
|
||||||
|
for (let index = 0; index < MAX_SNAPSHOTS + 3; index += 1) {
|
||||||
|
writeSnapshot(storage, [makeTask(`t${index}`)], at(index))
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(listSnapshots(storage)).toHaveLength(MAX_SNAPSHOTS)
|
||||||
|
expect(snapshotKeys(storage)).toHaveLength(MAX_SNAPSHOTS)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('保留的是最新的若干份', () => {
|
||||||
|
const storage = createFakeStorage()
|
||||||
|
for (let index = 0; index < 5; index += 1) {
|
||||||
|
writeSnapshot(storage, [makeTask(`t${index}`)], at(index))
|
||||||
|
}
|
||||||
|
const savedAtList = listSnapshots(storage).map((info) => info.savedAt)
|
||||||
|
expect(savedAtList).toEqual([at(4).toISOString(), at(3).toISOString(), at(2).toISOString()])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keep 可自定义', () => {
|
||||||
|
const storage = createFakeStorage()
|
||||||
|
writeSnapshot(storage, [makeTask('a')], at(0), 1)
|
||||||
|
writeSnapshot(storage, [makeTask('b')], at(1), 1)
|
||||||
|
expect(listSnapshots(storage)).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('存储写入失败时返回 null 而不抛错', () => {
|
||||||
|
const storage: StorageLike = {
|
||||||
|
getItem: () => null,
|
||||||
|
setItem: () => {
|
||||||
|
throw new Error('QuotaExceededError')
|
||||||
|
},
|
||||||
|
removeItem: () => undefined,
|
||||||
|
}
|
||||||
|
expect(writeSnapshot(storage, [makeTask('a')])).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('可以保存空看板(调用方决定是否调用)', () => {
|
||||||
|
const storage = createFakeStorage()
|
||||||
|
const info = writeSnapshot(storage, [], at(0))
|
||||||
|
expect(info).not.toBeNull()
|
||||||
|
expect(readSnapshot(storage, info?.key ?? '')).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('readSnapshot', () => {
|
||||||
|
it('读回写入的任务', () => {
|
||||||
|
const storage = createFakeStorage()
|
||||||
|
const tasks = [makeTask('a'), makeTask('b')]
|
||||||
|
const info = writeSnapshot(storage, tasks, at(0))
|
||||||
|
|
||||||
|
expect(readSnapshot(storage, info?.key ?? '')).toEqual(tasks)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('拒绝非快照键', () => {
|
||||||
|
const storage = createFakeStorage()
|
||||||
|
expect(() => readSnapshot(storage, 'kanban.tasks')).toThrow(DataValidationError)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('快照不存在时返回空数组(parsePersistedState 对空值返回空看板)', () => {
|
||||||
|
const storage = createFakeStorage()
|
||||||
|
expect(readSnapshot(storage, `${SNAPSHOT_KEY_PREFIX}missing`)).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('快照内容损坏时抛错', () => {
|
||||||
|
const storage = createFakeStorage({ [`${SNAPSHOT_KEY_PREFIX}bad`]: '{broken' })
|
||||||
|
expect(() => readSnapshot(storage, `${SNAPSHOT_KEY_PREFIX}bad`)).toThrow(DataValidationError)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
/**
|
||||||
|
* 启动快照(T14)
|
||||||
|
*
|
||||||
|
* local-first 场景下,localStorage 是唯一数据副本。除了手动导出,
|
||||||
|
* 每次启动再自动留一份快照,用于「主数据损坏/被误清」时的兜底恢复。
|
||||||
|
*
|
||||||
|
* 索引单独存放在 SNAPSHOT_INDEX_KEY 中,而不是靠枚举存储键,
|
||||||
|
* 这样只需 StorageLike 的最小能力即可实现。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Task } from '@/types/task'
|
||||||
|
|
||||||
|
import { DataValidationError, SNAPSHOT_KEY_PREFIX, parsePersistedState, serializeState } from './schema'
|
||||||
|
import type { StorageLike } from './storage'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 索引键刻意不使用 SNAPSHOT_KEY_PREFIX 前缀,
|
||||||
|
* 避免任何「按前缀枚举」的代码把它误当成一份快照。
|
||||||
|
*/
|
||||||
|
export const SNAPSHOT_INDEX_KEY = 'kanban.snapshots'
|
||||||
|
|
||||||
|
/** 最多保留的快照份数 */
|
||||||
|
export const MAX_SNAPSHOTS = 3
|
||||||
|
|
||||||
|
export interface SnapshotInfo {
|
||||||
|
key: string
|
||||||
|
savedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSnapshotInfo(value: unknown): value is SnapshotInfo {
|
||||||
|
if (typeof value !== 'object' || value === null) return false
|
||||||
|
const record = value as Record<string, unknown>
|
||||||
|
return typeof record.key === 'string' && typeof record.savedAt === 'string'
|
||||||
|
}
|
||||||
|
|
||||||
|
function readIndex(storage: StorageLike): SnapshotInfo[] {
|
||||||
|
const raw = storage.getItem(SNAPSHOT_INDEX_KEY)
|
||||||
|
if (!raw) return []
|
||||||
|
try {
|
||||||
|
const parsed: unknown = JSON.parse(raw)
|
||||||
|
if (!Array.isArray(parsed)) return []
|
||||||
|
return parsed.filter(isSnapshotInfo)
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 列出可用快照,按时间倒序;内容已丢失的条目会被跳过 */
|
||||||
|
export function listSnapshots(storage: StorageLike): SnapshotInfo[] {
|
||||||
|
return readIndex(storage).filter((info) => storage.getItem(info.key) !== null)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 写入一份快照,并只保留最近 keep 份(超出的连同数据一起删除)。
|
||||||
|
* 快照属于「锦上添花」,因此任何存储异常都返回 null 而不抛出,
|
||||||
|
* 避免因为备份失败而阻断正常使用。
|
||||||
|
*/
|
||||||
|
export function writeSnapshot(
|
||||||
|
storage: StorageLike,
|
||||||
|
tasks: readonly Task[],
|
||||||
|
now: Date = new Date(),
|
||||||
|
keep: number = MAX_SNAPSHOTS,
|
||||||
|
): SnapshotInfo | null {
|
||||||
|
const savedAt = now.toISOString()
|
||||||
|
const key = `${SNAPSHOT_KEY_PREFIX}${savedAt}`
|
||||||
|
const previous = readIndex(storage)
|
||||||
|
|
||||||
|
try {
|
||||||
|
storage.setItem(key, serializeState(tasks))
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const next = [{ key, savedAt }, ...previous].slice(0, Math.max(1, keep))
|
||||||
|
const keptKeys = new Set(next.map((info) => info.key))
|
||||||
|
|
||||||
|
for (const info of previous) {
|
||||||
|
if (keptKeys.has(info.key)) continue
|
||||||
|
try {
|
||||||
|
storage.removeItem(info.key)
|
||||||
|
} catch {
|
||||||
|
// 清理旧快照失败不影响本次快照
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
storage.setItem(SNAPSHOT_INDEX_KEY, JSON.stringify(next))
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return { key, savedAt }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 从指定快照读取任务 */
|
||||||
|
export function readSnapshot(storage: StorageLike, key: string): Task[] {
|
||||||
|
if (!key.startsWith(SNAPSHOT_KEY_PREFIX)) {
|
||||||
|
throw new DataValidationError(`不是合法的快照键:${key}`)
|
||||||
|
}
|
||||||
|
return parsePersistedState(storage.getItem(key)).tasks
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { afterEach, describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
import { resolveBrowserStorage } from './storage'
|
||||||
|
|
||||||
|
const originalLocalStorage = Object.getOwnPropertyDescriptor(globalThis, 'localStorage')
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
if (originalLocalStorage) Object.defineProperty(globalThis, 'localStorage', originalLocalStorage)
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('resolveBrowserStorage', () => {
|
||||||
|
it('localStorage 可写时返回它', () => {
|
||||||
|
const storage = resolveBrowserStorage()
|
||||||
|
expect(storage).not.toBeNull()
|
||||||
|
expect(typeof storage?.getItem).toBe('function')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('写入抛错时返回 null(Safari 隐私模式)', () => {
|
||||||
|
Object.defineProperty(globalThis, 'localStorage', {
|
||||||
|
configurable: true,
|
||||||
|
value: {
|
||||||
|
getItem: () => null,
|
||||||
|
setItem: () => {
|
||||||
|
throw new Error('SecurityError')
|
||||||
|
},
|
||||||
|
removeItem: () => undefined,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(resolveBrowserStorage()).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('localStorage 不存在时返回 null', () => {
|
||||||
|
Object.defineProperty(globalThis, 'localStorage', { configurable: true, value: undefined })
|
||||||
|
expect(resolveBrowserStorage()).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('探测用的临时键会被清理', () => {
|
||||||
|
const probeKeys: string[] = []
|
||||||
|
const data = new Map<string, string>()
|
||||||
|
Object.defineProperty(globalThis, 'localStorage', {
|
||||||
|
configurable: true,
|
||||||
|
value: {
|
||||||
|
getItem: (key: string) => data.get(key) ?? null,
|
||||||
|
setItem: (key: string, value: string) => {
|
||||||
|
probeKeys.push(key)
|
||||||
|
data.set(key, value)
|
||||||
|
},
|
||||||
|
removeItem: (key: string) => {
|
||||||
|
data.delete(key)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
resolveBrowserStorage()
|
||||||
|
|
||||||
|
expect(probeKeys).toContain('__kanban_probe__')
|
||||||
|
expect(data.size).toBe(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
/**
|
||||||
|
* 最小存储抽象(T04)
|
||||||
|
*
|
||||||
|
* 独立成模块是为了让 taskRepository 与 snapshot 都依赖它,
|
||||||
|
* 而不必互相 import(避免形成运行时循环依赖)。
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface StorageLike {
|
||||||
|
getItem(key: string): string | null
|
||||||
|
setItem(key: string, value: string): void
|
||||||
|
removeItem(key: string): void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 探测浏览器 localStorage 是否真正可写。
|
||||||
|
* Safari 隐私模式下 localStorage 存在但写入会抛错,因此必须实际试写一次。
|
||||||
|
*/
|
||||||
|
export function resolveBrowserStorage(): StorageLike | null {
|
||||||
|
try {
|
||||||
|
const storage = globalThis.localStorage
|
||||||
|
if (!storage) return null
|
||||||
|
const probeKey = '__kanban_probe__'
|
||||||
|
storage.setItem(probeKey, '1')
|
||||||
|
storage.removeItem(probeKey)
|
||||||
|
return storage
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
import type { Task } from '@/types/task'
|
||||||
|
|
||||||
|
import { DataValidationError, SCHEMA_VERSION, serializeState } from './schema'
|
||||||
|
import {
|
||||||
|
StorageUnavailableError,
|
||||||
|
StorageWriteError,
|
||||||
|
createLocalStorageTaskRepository,
|
||||||
|
createMemoryTaskRepository,
|
||||||
|
type StorageLike,
|
||||||
|
} from './taskRepository'
|
||||||
|
|
||||||
|
interface FakeStorage extends StorageLike {
|
||||||
|
data: Record<string, string>
|
||||||
|
}
|
||||||
|
|
||||||
|
function createFakeStorage(initial: Record<string, string> = {}): FakeStorage {
|
||||||
|
const data: Record<string, string> = { ...initial }
|
||||||
|
return {
|
||||||
|
data,
|
||||||
|
getItem: (key) => (key in data ? (data[key] ?? null) : null),
|
||||||
|
setItem: (key, value) => {
|
||||||
|
data[key] = value
|
||||||
|
},
|
||||||
|
removeItem: (key) => {
|
||||||
|
delete data[key]
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeTask(overrides: Partial<Task> = {}): Task {
|
||||||
|
return {
|
||||||
|
id: 'task-1',
|
||||||
|
title: '写方案',
|
||||||
|
description: '',
|
||||||
|
status: 'todo',
|
||||||
|
priority: 2,
|
||||||
|
tags: [],
|
||||||
|
dueDate: null,
|
||||||
|
sortOrder: 1024,
|
||||||
|
createdAt: '2026-10-18T00:00:00.000Z',
|
||||||
|
updatedAt: '2026-10-18T00:00:00.000Z',
|
||||||
|
completedAt: null,
|
||||||
|
archivedAt: null,
|
||||||
|
...overrides,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('createLocalStorageTaskRepository', () => {
|
||||||
|
it('存储不可用时抛出 StorageUnavailableError', () => {
|
||||||
|
expect(() => createLocalStorageTaskRepository(null)).toThrow(StorageUnavailableError)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('空存储读取为空数组', () => {
|
||||||
|
const repository = createLocalStorageTaskRepository(createFakeStorage(), 'kanban.tasks')
|
||||||
|
expect(repository.load()).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('保存后可以读回,且落盘内容带 schemaVersion', () => {
|
||||||
|
const storage = createFakeStorage()
|
||||||
|
const repository = createLocalStorageTaskRepository(storage, 'kanban.tasks')
|
||||||
|
const tasks = [makeTask(), makeTask({ id: 'task-2', status: 'done' })]
|
||||||
|
|
||||||
|
repository.save(tasks)
|
||||||
|
|
||||||
|
expect(repository.load()).toEqual(tasks)
|
||||||
|
const raw = storage.getItem('kanban.tasks')
|
||||||
|
expect(raw).not.toBeNull()
|
||||||
|
expect(JSON.parse(raw ?? '{}').schemaVersion).toBe(SCHEMA_VERSION)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('数据损坏时抛错,并把原始内容备份到 corrupt 键(不覆盖用户数据)', () => {
|
||||||
|
const storage = createFakeStorage({ 'kanban.tasks': '{broken json' })
|
||||||
|
const repository = createLocalStorageTaskRepository(storage, 'kanban.tasks')
|
||||||
|
|
||||||
|
expect(() => repository.load()).toThrow(DataValidationError)
|
||||||
|
|
||||||
|
const backupKeys = Object.keys(storage.data).filter((key) =>
|
||||||
|
key.startsWith('kanban.tasks.corrupt.'),
|
||||||
|
)
|
||||||
|
expect(backupKeys).toHaveLength(1)
|
||||||
|
expect(storage.data[backupKeys[0] ?? '']).toBe('{broken json')
|
||||||
|
// 原键未被清空,用户仍有机会手工恢复
|
||||||
|
expect(storage.getItem('kanban.tasks')).toBe('{broken json')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('写入失败时抛出 StorageWriteError', () => {
|
||||||
|
const storage: StorageLike = {
|
||||||
|
getItem: () => null,
|
||||||
|
setItem: () => {
|
||||||
|
throw new Error('QuotaExceededError')
|
||||||
|
},
|
||||||
|
removeItem: () => undefined,
|
||||||
|
}
|
||||||
|
const repository = createLocalStorageTaskRepository(storage, 'kanban.tasks')
|
||||||
|
expect(() => repository.save([makeTask()])).toThrow(StorageWriteError)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('使用默认键名 kanban.tasks', () => {
|
||||||
|
const storage = createFakeStorage()
|
||||||
|
const repository = createLocalStorageTaskRepository(storage)
|
||||||
|
repository.save([makeTask()])
|
||||||
|
expect(storage.getItem('kanban.tasks')).toBe(serializeState([makeTask()]))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('createMemoryTaskRepository', () => {
|
||||||
|
it('初始为空', () => {
|
||||||
|
expect(createMemoryTaskRepository().load()).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('接受初始数据并返回副本(外部修改不影响内部)', () => {
|
||||||
|
const initial = [makeTask()]
|
||||||
|
const repository = createMemoryTaskRepository(initial)
|
||||||
|
const loaded = repository.load()
|
||||||
|
loaded.push(makeTask({ id: 'task-2' }))
|
||||||
|
expect(repository.load()).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('保存后可以读回', () => {
|
||||||
|
const repository = createMemoryTaskRepository()
|
||||||
|
const tasks = [makeTask(), makeTask({ id: 'task-2' })]
|
||||||
|
repository.save(tasks)
|
||||||
|
expect(repository.load()).toEqual(tasks)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
/**
|
||||||
|
* 任务持久化端口(T04 / T14)
|
||||||
|
*
|
||||||
|
* 设计(见 docs/adr.md ADR-002):
|
||||||
|
* - 接口与实现分离,Store 只依赖 TaskRepository,不感知 localStorage
|
||||||
|
* - 采用「整份快照读写」而非细粒度 CRUD:数据量小(约 60KB),
|
||||||
|
* 整份写入不会出现部分写入的不一致状态,且排序/导入/跨标签页同步
|
||||||
|
* 本质上都是快照操作
|
||||||
|
* - 快照(snapshot)是同一端口上的附加能力:自动备份与恢复走这里,
|
||||||
|
* 调用方无需关心底层是 localStorage 还是内存
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Task } from '@/types/task'
|
||||||
|
|
||||||
|
import { DataValidationError, STORAGE_KEY, parsePersistedState, serializeState } from './schema'
|
||||||
|
import {
|
||||||
|
MAX_SNAPSHOTS,
|
||||||
|
listSnapshots,
|
||||||
|
readSnapshot,
|
||||||
|
writeSnapshot,
|
||||||
|
type SnapshotInfo,
|
||||||
|
} from './snapshot'
|
||||||
|
import { resolveBrowserStorage, type StorageLike } from './storage'
|
||||||
|
|
||||||
|
export type { StorageLike } from './storage'
|
||||||
|
export { resolveBrowserStorage } from './storage'
|
||||||
|
export type { SnapshotInfo } from './snapshot'
|
||||||
|
|
||||||
|
export interface TaskRepository {
|
||||||
|
/** 读取全部任务;数据为空时返回空数组 */
|
||||||
|
load(): Task[]
|
||||||
|
/** 覆盖写入全部任务 */
|
||||||
|
save(tasks: readonly Task[]): void
|
||||||
|
/** 写入一份快照并保留最近若干份;失败返回 null(不影响主流程) */
|
||||||
|
snapshot(tasks: readonly Task[]): SnapshotInfo | null
|
||||||
|
/** 列出可用快照,按时间倒序 */
|
||||||
|
snapshots(): SnapshotInfo[]
|
||||||
|
/** 从指定快照恢复任务 */
|
||||||
|
restoreSnapshot(key: string): Task[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export class StorageUnavailableError extends Error {
|
||||||
|
constructor(message: string) {
|
||||||
|
super(message)
|
||||||
|
this.name = 'StorageUnavailableError'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class StorageWriteError extends Error {
|
||||||
|
constructor(message: string) {
|
||||||
|
super(message)
|
||||||
|
this.name = 'StorageWriteError'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* localStorage 实现。
|
||||||
|
* 读取时若数据损坏,会先把原始内容备份到 `<key>.corrupt.<时间戳>` 再抛错,
|
||||||
|
* 避免后续写入把用户原本可能恢复的数据覆盖掉。
|
||||||
|
*/
|
||||||
|
export function createLocalStorageTaskRepository(
|
||||||
|
storage: StorageLike | null = resolveBrowserStorage(),
|
||||||
|
key: string = STORAGE_KEY,
|
||||||
|
): TaskRepository {
|
||||||
|
const target = storage
|
||||||
|
if (!target) {
|
||||||
|
throw new StorageUnavailableError('浏览器本地存储不可用(可能处于隐私模式或已被浏览器禁用)')
|
||||||
|
}
|
||||||
|
|
||||||
|
const backupCorrupt = (raw: string): string | null => {
|
||||||
|
try {
|
||||||
|
const backupKey = `${key}.corrupt.${Date.now()}`
|
||||||
|
target.setItem(backupKey, raw)
|
||||||
|
return backupKey
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
load() {
|
||||||
|
const raw = target.getItem(key)
|
||||||
|
try {
|
||||||
|
return parsePersistedState(raw).tasks
|
||||||
|
} catch (error) {
|
||||||
|
if (raw !== null && error instanceof DataValidationError) {
|
||||||
|
const backupKey = backupCorrupt(raw)
|
||||||
|
const suffix = backupKey ? `,原数据已备份到 ${backupKey}` : ''
|
||||||
|
throw new DataValidationError(`${error.message}${suffix}`)
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
save(tasks) {
|
||||||
|
try {
|
||||||
|
target.setItem(key, serializeState(tasks))
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error)
|
||||||
|
throw new StorageWriteError(`写入本地存储失败:${message}`)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
snapshot: (tasks) => writeSnapshot(target, tasks),
|
||||||
|
snapshots: () => listSnapshots(target),
|
||||||
|
restoreSnapshot: (snapshotKey) => readSnapshot(target, snapshotKey),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MemorySnapshot {
|
||||||
|
key: string
|
||||||
|
savedAt: string
|
||||||
|
tasks: Task[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 内存实现:用于测试,以及 localStorage 不可用时的降级(此时数据不会被保存) */
|
||||||
|
export function createMemoryTaskRepository(initial: readonly Task[] = []): TaskRepository {
|
||||||
|
let cache: Task[] = [...initial]
|
||||||
|
let snapshots: MemorySnapshot[] = []
|
||||||
|
|
||||||
|
return {
|
||||||
|
load: () => [...cache],
|
||||||
|
|
||||||
|
save: (tasks) => {
|
||||||
|
cache = [...tasks]
|
||||||
|
},
|
||||||
|
|
||||||
|
snapshot(tasks) {
|
||||||
|
const savedAt = new Date().toISOString()
|
||||||
|
const key = `memory-snapshot:${savedAt}`
|
||||||
|
snapshots = [
|
||||||
|
{ key, savedAt, tasks: [...tasks] },
|
||||||
|
...snapshots.filter((item) => item.key !== key),
|
||||||
|
].slice(0, MAX_SNAPSHOTS)
|
||||||
|
return { key, savedAt }
|
||||||
|
},
|
||||||
|
|
||||||
|
snapshots: () => snapshots.map(({ key, savedAt }) => ({ key, savedAt })),
|
||||||
|
|
||||||
|
restoreSnapshot(key) {
|
||||||
|
const found = snapshots.find((item) => item.key === key)
|
||||||
|
if (!found) throw new DataValidationError(`快照不存在:${key}`)
|
||||||
|
return [...found.tasks]
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { createPinia } from 'pinia'
|
||||||
|
import { createApp } from 'vue'
|
||||||
|
|
||||||
|
import App from './App.vue'
|
||||||
|
import { createAppRouter } from './router'
|
||||||
|
import './style.css'
|
||||||
|
|
||||||
|
createApp(App).use(createPinia()).use(createAppRouter()).mount('#app')
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
/**
|
||||||
|
* 路由(见 docs/adr.md ADR-005)
|
||||||
|
*
|
||||||
|
* ADR-004 当初决定不引入路由,并注明「若未来增加今日视图、统计等真正的独立页面,
|
||||||
|
* 再引入路由」。现在正是那个时刻:今日视图是与看板并列的独立视图,
|
||||||
|
* 而且 PWA 的 manifest shortcuts 需要能深链到 /today。
|
||||||
|
*
|
||||||
|
* 导出工厂函数而非单例,便于测试注入 memory history。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { RouterHistory } from 'vue-router'
|
||||||
|
import { createRouter, createWebHistory } from 'vue-router'
|
||||||
|
|
||||||
|
import BoardView from '@/views/BoardView.vue'
|
||||||
|
|
||||||
|
export const routes = [
|
||||||
|
{
|
||||||
|
path: '/',
|
||||||
|
name: 'board',
|
||||||
|
component: BoardView,
|
||||||
|
meta: { title: '看板' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/today',
|
||||||
|
name: 'today',
|
||||||
|
component: () => import('@/views/TodayView.vue'),
|
||||||
|
meta: { title: '今日' },
|
||||||
|
},
|
||||||
|
{ path: '/:pathMatch(.*)*', redirect: '/' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export function createAppRouter(history: RouterHistory = createWebHistory()): ReturnType<typeof createRouter> {
|
||||||
|
return createRouter({ history, routes })
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
import { createPinia, setActivePinia } from 'pinia'
|
||||||
|
import { nextTick } from 'vue'
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
import { KEYWORD_DEBOUNCE_MS, useFilterStore } from './filters'
|
||||||
|
|
||||||
|
function setup() {
|
||||||
|
setActivePinia(createPinia())
|
||||||
|
return useFilterStore()
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('筛选条件切换', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
setActivePinia(createPinia())
|
||||||
|
})
|
||||||
|
|
||||||
|
it('初始为空且未激活', () => {
|
||||||
|
const store = setup()
|
||||||
|
expect(store.active).toBe(false)
|
||||||
|
expect(store.priorities).toEqual([])
|
||||||
|
expect(store.tags).toEqual([])
|
||||||
|
expect(store.due).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('优先级可反复切换', () => {
|
||||||
|
const store = setup()
|
||||||
|
store.togglePriority(3)
|
||||||
|
expect(store.priorities).toEqual([3])
|
||||||
|
|
||||||
|
store.togglePriority(1)
|
||||||
|
expect(store.priorities).toEqual([3, 1])
|
||||||
|
|
||||||
|
store.togglePriority(3)
|
||||||
|
expect(store.priorities).toEqual([1])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('标签可反复切换', () => {
|
||||||
|
const store = setup()
|
||||||
|
store.toggleTag('设计')
|
||||||
|
expect(store.tags).toEqual(['设计'])
|
||||||
|
store.toggleTag('设计')
|
||||||
|
expect(store.tags).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('截止日期条件再次点击即取消', () => {
|
||||||
|
const store = setup()
|
||||||
|
store.toggleDue('overdue')
|
||||||
|
expect(store.due).toBe('overdue')
|
||||||
|
store.toggleDue('overdue')
|
||||||
|
expect(store.due).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('切换条件后 active 变为 true', () => {
|
||||||
|
const store = setup()
|
||||||
|
store.togglePriority(3)
|
||||||
|
expect(store.active).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('关键词防抖', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
setActivePinia(createPinia())
|
||||||
|
vi.useFakeTimers()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('防抖时间到之前不参与筛选', async () => {
|
||||||
|
const store = setup()
|
||||||
|
store.keyword = '方案'
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
expect(store.filter.keyword).toBe('')
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(KEYWORD_DEBOUNCE_MS)
|
||||||
|
expect(store.filter.keyword).toBe('方案')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('连续输入只保留最后一次', async () => {
|
||||||
|
const store = setup()
|
||||||
|
store.keyword = '方'
|
||||||
|
await nextTick()
|
||||||
|
vi.advanceTimersByTime(100)
|
||||||
|
|
||||||
|
store.keyword = '方案'
|
||||||
|
await nextTick()
|
||||||
|
vi.advanceTimersByTime(KEYWORD_DEBOUNCE_MS)
|
||||||
|
|
||||||
|
expect(store.filter.keyword).toBe('方案')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('active 使用即时关键词,输入后立刻为真(拖拽保护不留空窗)', async () => {
|
||||||
|
const store = setup()
|
||||||
|
store.keyword = '方'
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
expect(store.filter.keyword).toBe('')
|
||||||
|
expect(store.active).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('清空筛选', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
setActivePinia(createPinia())
|
||||||
|
vi.useFakeTimers()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('立即清空全部条件(不等待防抖)', async () => {
|
||||||
|
const store = setup()
|
||||||
|
store.keyword = '方案'
|
||||||
|
await nextTick()
|
||||||
|
vi.advanceTimersByTime(KEYWORD_DEBOUNCE_MS)
|
||||||
|
store.togglePriority(3)
|
||||||
|
store.toggleTag('设计')
|
||||||
|
store.toggleDue('today')
|
||||||
|
|
||||||
|
store.clear()
|
||||||
|
|
||||||
|
expect(store.keyword).toBe('')
|
||||||
|
expect(store.filter.keyword).toBe('')
|
||||||
|
expect(store.filter.priorities).toEqual([])
|
||||||
|
expect(store.filter.tags).toEqual([])
|
||||||
|
expect(store.filter.due).toBeNull()
|
||||||
|
expect(store.active).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('清空后残留的防抖回调不会把关键词写回来', async () => {
|
||||||
|
const store = setup()
|
||||||
|
store.keyword = '方案'
|
||||||
|
await nextTick()
|
||||||
|
store.clear()
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(KEYWORD_DEBOUNCE_MS)
|
||||||
|
expect(store.filter.keyword).toBe('')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
/**
|
||||||
|
* 筛选状态(T15 / T16)
|
||||||
|
*
|
||||||
|
* 关键词做防抖:每次按键都会重算分组并重渲染整块看板,
|
||||||
|
* 防抖可以避免连续输入时的重复渲染。
|
||||||
|
*
|
||||||
|
* 注意 active 用的是「即时关键词」而非防抖后的值 —— 这样拖拽保护
|
||||||
|
* 会在用户刚开始输入时就生效,不留空窗期。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useDebounceFn } from '@vueuse/core'
|
||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { computed, ref, watch } from 'vue'
|
||||||
|
|
||||||
|
import type { Priority } from '@/types/task'
|
||||||
|
import { isFilterActive, type DueFilter, type TaskFilter } from '@/utils/filter'
|
||||||
|
|
||||||
|
export const KEYWORD_DEBOUNCE_MS = 200
|
||||||
|
|
||||||
|
export const useFilterStore = defineStore('filters', () => {
|
||||||
|
/** 输入框中的即时关键词 */
|
||||||
|
const keyword = ref('')
|
||||||
|
/** 实际参与筛选的关键词(防抖后) */
|
||||||
|
const debouncedKeyword = ref('')
|
||||||
|
const priorities = ref<Priority[]>([])
|
||||||
|
const tags = ref<string[]>([])
|
||||||
|
const due = ref<DueFilter | null>(null)
|
||||||
|
/** 是否显示已归档任务(视图开关,不算筛选条件) */
|
||||||
|
const includeArchived = ref(false)
|
||||||
|
|
||||||
|
const applyKeyword = useDebounceFn((value: string) => {
|
||||||
|
// 清空筛选后可能仍有在途的防抖回调,此时以当前 keyword 为准,丢弃过期值
|
||||||
|
if (value !== keyword.value) return
|
||||||
|
debouncedKeyword.value = value
|
||||||
|
}, KEYWORD_DEBOUNCE_MS)
|
||||||
|
|
||||||
|
watch(keyword, (value) => applyKeyword(value))
|
||||||
|
|
||||||
|
/** 实际参与筛选的条件 */
|
||||||
|
const filter = computed<TaskFilter>(() => ({
|
||||||
|
keyword: debouncedKeyword.value,
|
||||||
|
priorities: priorities.value,
|
||||||
|
tags: tags.value,
|
||||||
|
due: due.value,
|
||||||
|
includeArchived: includeArchived.value,
|
||||||
|
}))
|
||||||
|
|
||||||
|
/** 是否处于筛选态(拖拽保护依据) */
|
||||||
|
const active = computed(() =>
|
||||||
|
isFilterActive({
|
||||||
|
keyword: keyword.value,
|
||||||
|
priorities: priorities.value,
|
||||||
|
tags: tags.value,
|
||||||
|
due: due.value,
|
||||||
|
includeArchived: includeArchived.value,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
function togglePriority(value: Priority): void {
|
||||||
|
priorities.value = priorities.value.includes(value)
|
||||||
|
? priorities.value.filter((item) => item !== value)
|
||||||
|
: [...priorities.value, value]
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleTag(value: string): void {
|
||||||
|
tags.value = tags.value.includes(value)
|
||||||
|
? tags.value.filter((item) => item !== value)
|
||||||
|
: [...tags.value, value]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 再次点击同一条件即取消 */
|
||||||
|
function toggleDue(value: DueFilter): void {
|
||||||
|
due.value = due.value === value ? null : value
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleArchived(): void {
|
||||||
|
includeArchived.value = !includeArchived.value
|
||||||
|
}
|
||||||
|
|
||||||
|
function clear(): void {
|
||||||
|
keyword.value = ''
|
||||||
|
debouncedKeyword.value = ''
|
||||||
|
priorities.value = []
|
||||||
|
tags.value = []
|
||||||
|
due.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
keyword,
|
||||||
|
debouncedKeyword,
|
||||||
|
priorities,
|
||||||
|
tags,
|
||||||
|
due,
|
||||||
|
includeArchived,
|
||||||
|
filter,
|
||||||
|
active,
|
||||||
|
togglePriority,
|
||||||
|
toggleTag,
|
||||||
|
toggleDue,
|
||||||
|
toggleArchived,
|
||||||
|
clear,
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,446 @@
|
|||||||
|
import { createPinia, setActivePinia } from 'pinia'
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
import { createMemoryTaskRepository, type TaskRepository } from '@/data/taskRepository'
|
||||||
|
|
||||||
|
import { useTaskStore } from './tasks'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 每次调用都换一个全新的 Pinia 实例,以模拟「重新打开页面」。
|
||||||
|
* crossTab 关闭,避免测试环境创建 BroadcastChannel。
|
||||||
|
*/
|
||||||
|
function freshStore(repository: TaskRepository = createMemoryTaskRepository()) {
|
||||||
|
setActivePinia(createPinia())
|
||||||
|
const store = useTaskStore()
|
||||||
|
store.init({ repository, crossTab: false })
|
||||||
|
return store
|
||||||
|
}
|
||||||
|
|
||||||
|
function titles(tasks: readonly { title: string }[]): string[] {
|
||||||
|
return tasks.map((task) => task.title)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('初始化', () => {
|
||||||
|
it('从仓库加载已有数据', () => {
|
||||||
|
const repository = createMemoryTaskRepository([
|
||||||
|
{
|
||||||
|
id: 'existing',
|
||||||
|
title: '已有任务',
|
||||||
|
description: '',
|
||||||
|
status: 'doing',
|
||||||
|
priority: 3,
|
||||||
|
tags: [],
|
||||||
|
dueDate: null,
|
||||||
|
sortOrder: 1024,
|
||||||
|
createdAt: '2026-10-18T00:00:00.000Z',
|
||||||
|
updatedAt: '2026-10-18T00:00:00.000Z',
|
||||||
|
completedAt: null,
|
||||||
|
archivedAt: null,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
const store = freshStore(repository)
|
||||||
|
|
||||||
|
expect(store.ready).toBe(true)
|
||||||
|
expect(store.tasks).toHaveLength(1)
|
||||||
|
expect(store.byColumn.doing.map((task) => task.title)).toEqual(['已有任务'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('counts 反映各列数量', () => {
|
||||||
|
const store = freshStore()
|
||||||
|
store.createTask({ title: 'A' })
|
||||||
|
store.createTask({ title: 'B', status: 'doing' })
|
||||||
|
store.createTask({ title: 'C', status: 'done' })
|
||||||
|
expect(store.counts).toEqual({ todo: 1, doing: 1, done: 1 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('重复调用 init 不会清空数据', () => {
|
||||||
|
const store = freshStore()
|
||||||
|
store.createTask({ title: 'A' })
|
||||||
|
store.init({ crossTab: false })
|
||||||
|
expect(store.tasks).toHaveLength(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('快速新增', () => {
|
||||||
|
it('新任务落到待办列首', () => {
|
||||||
|
const store = freshStore()
|
||||||
|
store.createTask({ title: '先建的' })
|
||||||
|
store.createTask({ title: '后建的' })
|
||||||
|
expect(titles(store.byColumn.todo)).toEqual(['后建的', '先建的'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('空标题或纯空白不创建任务', () => {
|
||||||
|
const store = freshStore()
|
||||||
|
expect(store.createTask({ title: ' ' })).toBeNull()
|
||||||
|
expect(store.createTask({ title: '' })).toBeNull()
|
||||||
|
expect(store.tasks).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('标题首尾空白被去除', () => {
|
||||||
|
const store = freshStore()
|
||||||
|
const task = store.createTask({ title: ' 写方案 ' })
|
||||||
|
expect(task?.title).toBe('写方案')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('标签去重并去除空白项', () => {
|
||||||
|
const store = freshStore()
|
||||||
|
const task = store.createTask({ title: 'A', tags: ['设计', ' 设计 ', '', '前端'] })
|
||||||
|
expect(task?.tags).toEqual(['设计', '前端'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('直接建到已完成列时写入 completedAt', () => {
|
||||||
|
const store = freshStore()
|
||||||
|
const task = store.createTask({ title: 'A', status: 'done' })
|
||||||
|
expect(task?.completedAt).not.toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('默认优先级为中', () => {
|
||||||
|
const store = freshStore()
|
||||||
|
expect(store.createTask({ title: 'A' })?.priority).toBe(2)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('编辑', () => {
|
||||||
|
it('修改标题并刷新 updatedAt', async () => {
|
||||||
|
const store = freshStore()
|
||||||
|
const task = store.createTask({ title: '旧标题' })
|
||||||
|
const before = task?.updatedAt ?? ''
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 2))
|
||||||
|
const updated = store.updateTask(task?.id ?? '', { title: '新标题' })
|
||||||
|
|
||||||
|
expect(updated?.title).toBe('新标题')
|
||||||
|
expect(updated?.updatedAt).not.toBe(before)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('标题被清空时保留原标题', () => {
|
||||||
|
const store = freshStore()
|
||||||
|
const task = store.createTask({ title: '原标题' })
|
||||||
|
expect(store.updateTask(task?.id ?? '', { title: ' ' })?.title).toBe('原标题')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('移到已完成写入 completedAt,移出时清空', () => {
|
||||||
|
const store = freshStore()
|
||||||
|
const task = store.createTask({ title: 'A' })
|
||||||
|
const id = task?.id ?? ''
|
||||||
|
|
||||||
|
store.updateTask(id, { status: 'done' })
|
||||||
|
expect(store.tasks.find((item) => item.id === id)?.completedAt).not.toBeNull()
|
||||||
|
|
||||||
|
store.updateTask(id, { status: 'todo' })
|
||||||
|
expect(store.tasks.find((item) => item.id === id)?.completedAt).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('状态变更后落到目标列末尾', () => {
|
||||||
|
const store = freshStore()
|
||||||
|
const first = store.createTask({ title: '已在进行中', status: 'doing' })
|
||||||
|
const moved = store.createTask({ title: '从待办移入' })
|
||||||
|
store.updateTask(moved?.id ?? '', { status: 'doing' })
|
||||||
|
|
||||||
|
expect(titles(store.byColumn.doing)).toEqual(['已在进行中', '从待办移入'])
|
||||||
|
expect(first?.status).toBe('doing')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('对不存在的 id 返回 null', () => {
|
||||||
|
const store = freshStore()
|
||||||
|
expect(store.updateTask('missing', { title: 'x' })).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('删除与撤销', () => {
|
||||||
|
it('删除后返回被删任务', () => {
|
||||||
|
const store = freshStore()
|
||||||
|
const task = store.createTask({ title: 'A' })
|
||||||
|
expect(store.removeTask(task?.id ?? '')?.title).toBe('A')
|
||||||
|
expect(store.tasks).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('撤销后连原顺序一起恢复', () => {
|
||||||
|
const store = freshStore()
|
||||||
|
store.createTask({ title: 'A' })
|
||||||
|
const middle = store.createTask({ title: 'B' })
|
||||||
|
store.createTask({ title: 'C' })
|
||||||
|
expect(titles(store.byColumn.todo)).toEqual(['C', 'B', 'A'])
|
||||||
|
|
||||||
|
const removed = store.removeTask(middle?.id ?? '')
|
||||||
|
expect(titles(store.byColumn.todo)).toEqual(['C', 'A'])
|
||||||
|
|
||||||
|
store.restoreTask(removed!)
|
||||||
|
expect(titles(store.byColumn.todo)).toEqual(['C', 'B', 'A'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('重复撤销不会产生重复任务', () => {
|
||||||
|
const store = freshStore()
|
||||||
|
const task = store.createTask({ title: 'A' })
|
||||||
|
const removed = store.removeTask(task?.id ?? '')
|
||||||
|
store.restoreTask(removed!)
|
||||||
|
store.restoreTask(removed!)
|
||||||
|
expect(store.tasks).toHaveLength(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('拖拽移动', () => {
|
||||||
|
it('跨列拖动更新 status 与 completedAt', () => {
|
||||||
|
const store = freshStore()
|
||||||
|
const task = store.createTask({ title: 'A' })
|
||||||
|
store.moveTask(task?.id ?? '', 'done', 0)
|
||||||
|
|
||||||
|
const moved = store.tasks.find((item) => item.id === task?.id)
|
||||||
|
expect(moved?.status).toBe('done')
|
||||||
|
expect(moved?.completedAt).not.toBeNull()
|
||||||
|
expect(titles(store.byColumn.done)).toEqual(['A'])
|
||||||
|
expect(store.byColumn.todo).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('从已完成拖回待办时清空 completedAt', () => {
|
||||||
|
const store = freshStore()
|
||||||
|
const task = store.createTask({ title: 'A', status: 'done' })
|
||||||
|
store.moveTask(task?.id ?? '', 'todo', 0)
|
||||||
|
expect(store.tasks.find((item) => item.id === task?.id)?.completedAt).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('跨列拖动到指定下标', () => {
|
||||||
|
const store = freshStore()
|
||||||
|
store.createTask({ title: '目标列第一', status: 'doing' })
|
||||||
|
store.createTask({ title: '目标列第二', status: 'doing' })
|
||||||
|
const incoming = store.createTask({ title: '移入' })
|
||||||
|
|
||||||
|
// 快速新增是「插入列首」,因此目标列当前顺序为:目标列第二, 目标列第一
|
||||||
|
expect(titles(store.byColumn.doing)).toEqual(['目标列第二', '目标列第一'])
|
||||||
|
|
||||||
|
store.moveTask(incoming?.id ?? '', 'doing', 1)
|
||||||
|
|
||||||
|
expect(titles(store.byColumn.doing)).toEqual(['目标列第二', '移入', '目标列第一'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('列内拖动改变顺序', () => {
|
||||||
|
const store = freshStore()
|
||||||
|
const a = store.createTask({ title: 'A' })
|
||||||
|
store.createTask({ title: 'B' })
|
||||||
|
store.createTask({ title: 'C' })
|
||||||
|
expect(titles(store.byColumn.todo)).toEqual(['C', 'B', 'A'])
|
||||||
|
|
||||||
|
store.moveTask(a?.id ?? '', 'todo', 0)
|
||||||
|
expect(titles(store.byColumn.todo)).toEqual(['A', 'C', 'B'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('列内拖动后重建 store 顺序仍保持(刷新不丢)', () => {
|
||||||
|
const repository = createMemoryTaskRepository()
|
||||||
|
const first = freshStore(repository)
|
||||||
|
const a = first.createTask({ title: 'A' })
|
||||||
|
first.createTask({ title: 'B' })
|
||||||
|
first.createTask({ title: 'C' })
|
||||||
|
first.moveTask(a?.id ?? '', 'todo', 0)
|
||||||
|
|
||||||
|
const second = freshStore(repository)
|
||||||
|
expect(titles(second.byColumn.todo)).toEqual(['A', 'C', 'B'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('跨列拖动后重建 store,状态与位置都保持', () => {
|
||||||
|
const repository = createMemoryTaskRepository()
|
||||||
|
const first = freshStore(repository)
|
||||||
|
const task = first.createTask({ title: 'A' })
|
||||||
|
first.moveTask(task?.id ?? '', 'doing', 0)
|
||||||
|
|
||||||
|
const second = freshStore(repository)
|
||||||
|
expect(second.byColumn.doing.map((item) => item.title)).toEqual(['A'])
|
||||||
|
expect(second.byColumn.todo).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('对不存在的 id 无副作用', () => {
|
||||||
|
const store = freshStore()
|
||||||
|
store.createTask({ title: 'A' })
|
||||||
|
store.moveTask('missing', 'done', 0)
|
||||||
|
expect(titles(store.byColumn.todo)).toEqual(['A'])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('持久化', () => {
|
||||||
|
it('新增后重建 store 能读回(刷新不丢)', () => {
|
||||||
|
const repository = createMemoryTaskRepository()
|
||||||
|
const first = freshStore(repository)
|
||||||
|
first.createTask({ title: '持久化任务' })
|
||||||
|
|
||||||
|
const second = freshStore(repository)
|
||||||
|
expect(titles(second.tasks)).toEqual(['持久化任务'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('删除后重建 store 不会复活任务', () => {
|
||||||
|
const repository = createMemoryTaskRepository()
|
||||||
|
const first = freshStore(repository)
|
||||||
|
const task = first.createTask({ title: 'A' })
|
||||||
|
first.removeTask(task?.id ?? '')
|
||||||
|
|
||||||
|
const second = freshStore(repository)
|
||||||
|
expect(second.tasks).toHaveLength(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('导出与导入', () => {
|
||||||
|
it('导出后再导入,数据一致', () => {
|
||||||
|
const store = freshStore()
|
||||||
|
store.createTask({ title: 'A', priority: 3, tags: ['x', 'x', 'y'] })
|
||||||
|
store.createTask({ title: 'B', status: 'doing' })
|
||||||
|
const json = store.exportJson()
|
||||||
|
|
||||||
|
const other = freshStore()
|
||||||
|
expect(other.importJson(json)).toBe(2)
|
||||||
|
expect(titles(other.tasks).sort()).toEqual(['A', 'B'])
|
||||||
|
expect(other.tasks.find((task) => task.title === 'A')?.tags).toEqual(['x', 'y'])
|
||||||
|
expect(other.tasks.find((task) => task.title === 'B')?.status).toBe('doing')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('导入非法数据时抛错且现有数据不变', () => {
|
||||||
|
const store = freshStore()
|
||||||
|
store.createTask({ title: '保留我' })
|
||||||
|
|
||||||
|
expect(() => store.importJson('{"tasks":[{"id":"x"}]}')).toThrow()
|
||||||
|
expect(titles(store.tasks)).toEqual(['保留我'])
|
||||||
|
|
||||||
|
expect(() => store.importJson('不是 JSON')).toThrow()
|
||||||
|
expect(titles(store.tasks)).toEqual(['保留我'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('导入为覆盖式', () => {
|
||||||
|
const store = freshStore()
|
||||||
|
store.createTask({ title: '旧数据' })
|
||||||
|
const payload = JSON.stringify({
|
||||||
|
schemaVersion: 1,
|
||||||
|
tasks: [
|
||||||
|
{
|
||||||
|
id: 'new',
|
||||||
|
title: '新数据',
|
||||||
|
status: 'todo',
|
||||||
|
priority: 2,
|
||||||
|
sortOrder: 1024,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
store.importJson(payload)
|
||||||
|
expect(titles(store.tasks)).toEqual(['新数据'])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('归档', () => {
|
||||||
|
it('新建任务默认未归档', () => {
|
||||||
|
const store = freshStore()
|
||||||
|
expect(store.createTask({ title: 'A' })?.archivedAt).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('归档写入 archivedAt 并返回数量', () => {
|
||||||
|
const store = freshStore()
|
||||||
|
const task = store.createTask({ title: 'A', status: 'done' })
|
||||||
|
|
||||||
|
expect(store.archiveTasks([task?.id ?? ''])).toBe(1)
|
||||||
|
expect(store.tasks[0]?.archivedAt).not.toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('重复归档同一任务不重复计数', () => {
|
||||||
|
const store = freshStore()
|
||||||
|
const task = store.createTask({ title: 'A', status: 'done' })
|
||||||
|
const id = task?.id ?? ''
|
||||||
|
|
||||||
|
expect(store.archiveTasks([id])).toBe(1)
|
||||||
|
expect(store.archiveTasks([id])).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('对不存在的 id 无副作用', () => {
|
||||||
|
const store = freshStore()
|
||||||
|
store.createTask({ title: 'A' })
|
||||||
|
expect(store.archiveTasks(['missing'])).toBe(0)
|
||||||
|
expect(store.tasks[0]?.archivedAt).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('取消归档清空 archivedAt', () => {
|
||||||
|
const store = freshStore()
|
||||||
|
const task = store.createTask({ title: 'A', status: 'done' })
|
||||||
|
const id = task?.id ?? ''
|
||||||
|
|
||||||
|
store.archiveTasks([id])
|
||||||
|
expect(store.unarchiveTasks([id])).toBe(1)
|
||||||
|
expect(store.tasks[0]?.archivedAt).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('取消未归档的任务不计数', () => {
|
||||||
|
const store = freshStore()
|
||||||
|
const task = store.createTask({ title: 'A' })
|
||||||
|
expect(store.unarchiveTasks([task?.id ?? ''])).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('归档状态在重建 store 后保留(刷新不丢)', () => {
|
||||||
|
const repository = createMemoryTaskRepository()
|
||||||
|
const first = freshStore(repository)
|
||||||
|
const task = first.createTask({ title: 'A', status: 'done' })
|
||||||
|
first.archiveTasks([task?.id ?? ''])
|
||||||
|
|
||||||
|
const second = freshStore(repository)
|
||||||
|
expect(second.tasks[0]?.archivedAt).not.toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('批量归档返回实际归档数量', () => {
|
||||||
|
const store = freshStore()
|
||||||
|
const a = store.createTask({ title: 'A', status: 'done' })
|
||||||
|
const b = store.createTask({ title: 'B', status: 'done' })
|
||||||
|
const c = store.createTask({ title: 'C' })
|
||||||
|
|
||||||
|
const count = store.archiveTasks([a?.id ?? '', b?.id ?? '', c?.id ?? ''])
|
||||||
|
expect(count).toBe(3)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('归档不影响任务内容本身', () => {
|
||||||
|
const store = freshStore()
|
||||||
|
const task = store.createTask({ title: '保留标题', description: '保留描述', status: 'done' })
|
||||||
|
store.archiveTasks([task?.id ?? ''])
|
||||||
|
|
||||||
|
expect(store.tasks[0]?.title).toBe('保留标题')
|
||||||
|
expect(store.tasks[0]?.description).toBe('保留描述')
|
||||||
|
expect(store.tasks[0]?.status).toBe('done')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('规模与稳定性', () => {
|
||||||
|
it('200 个任务时新增、拖动、归档后数据仍自洽', () => {
|
||||||
|
const store = freshStore()
|
||||||
|
for (let index = 0; index < 200; index += 1) {
|
||||||
|
store.createTask({ title: `任务 ${index}` })
|
||||||
|
}
|
||||||
|
expect(store.byColumn.todo).toHaveLength(200)
|
||||||
|
|
||||||
|
// 反复把最后一张卡片拖到列首,验证下标换算与精度保护不退化
|
||||||
|
for (let round = 0; round < 50; round += 1) {
|
||||||
|
const last = store.byColumn.todo[199]
|
||||||
|
if (!last) break
|
||||||
|
store.moveTask(last.id, 'todo', 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
const column = store.byColumn.todo
|
||||||
|
expect(column).toHaveLength(200)
|
||||||
|
expect(new Set(column.map((task) => task.id)).size).toBe(200)
|
||||||
|
|
||||||
|
const orders = column.map((task) => task.sortOrder)
|
||||||
|
expect([...orders].sort((a, b) => a - b)).toEqual(orders)
|
||||||
|
for (let index = 1; index < orders.length; index += 1) {
|
||||||
|
const previous = orders[index - 1] ?? 0
|
||||||
|
const current = orders[index] ?? 0
|
||||||
|
expect(current - previous).toBeGreaterThan(0)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('批量跨列移动后各列数量守恒', () => {
|
||||||
|
const store = freshStore()
|
||||||
|
const ids: string[] = []
|
||||||
|
for (let index = 0; index < 60; index += 1) {
|
||||||
|
const task = store.createTask({ title: `任务 ${index}` })
|
||||||
|
if (task) ids.push(task.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [index, id] of ids.entries()) {
|
||||||
|
store.moveTask(id, index % 2 === 0 ? 'doing' : 'done', 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(store.byColumn.todo).toHaveLength(0)
|
||||||
|
expect(store.byColumn.doing).toHaveLength(30)
|
||||||
|
expect(store.byColumn.done).toHaveLength(30)
|
||||||
|
expect(store.tasks).toHaveLength(60)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,409 @@
|
|||||||
|
/**
|
||||||
|
* 任务 Store(T06)
|
||||||
|
*
|
||||||
|
* 职责边界:
|
||||||
|
* - Store 是内存态的唯一来源,所有读写都经由 TaskRepository
|
||||||
|
* - 每次变更后 persist + broadcast,保证刷新不丢、多标签页一致
|
||||||
|
* - 纯计算(排序、分组)委托给 @/utils/sortOrder,便于单独测试
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
|
||||||
|
import { parseImportJson, serializeState } from '@/data/schema'
|
||||||
|
import {
|
||||||
|
createLocalStorageTaskRepository,
|
||||||
|
createMemoryTaskRepository,
|
||||||
|
type SnapshotInfo,
|
||||||
|
type TaskRepository,
|
||||||
|
} from '@/data/taskRepository'
|
||||||
|
import { DEFAULT_PRIORITY, type ColumnId, type Priority, type Task } from '@/types/task'
|
||||||
|
import { createId } from '@/utils/id'
|
||||||
|
import { groupByColumn, moveWithinColumn, sortByOrder, sortOrderForPosition } from '@/utils/sortOrder'
|
||||||
|
|
||||||
|
/** 跨标签页同步使用的频道名 */
|
||||||
|
export const SYNC_CHANNEL_NAME = 'kanban.sync'
|
||||||
|
|
||||||
|
export interface CreateTaskInput {
|
||||||
|
title: string
|
||||||
|
description?: string
|
||||||
|
priority?: Priority
|
||||||
|
tags?: string[]
|
||||||
|
dueDate?: string | null
|
||||||
|
status?: ColumnId
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TaskPatch = Partial<Omit<Task, 'id' | 'createdAt'>>
|
||||||
|
|
||||||
|
export interface InitOptions {
|
||||||
|
/** 注入仓库;不传则使用 localStorage,不可用时降级为内存仓库 */
|
||||||
|
repository?: TaskRepository
|
||||||
|
/** 是否启用跨标签页同步,测试中应关闭 */
|
||||||
|
crossTab?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SyncMessage {
|
||||||
|
type: 'tasks-changed'
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSyncMessage(value: unknown): value is SyncMessage {
|
||||||
|
return (
|
||||||
|
typeof value === 'object' &&
|
||||||
|
value !== null &&
|
||||||
|
(value as { type?: unknown }).type === 'tasks-changed'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeTags(tags: readonly string[]): string[] {
|
||||||
|
const seen = new Set<string>()
|
||||||
|
const result: string[] = []
|
||||||
|
for (const tag of tags) {
|
||||||
|
const value = tag.trim()
|
||||||
|
if (!value || seen.has(value)) continue
|
||||||
|
seen.add(value)
|
||||||
|
result.push(value)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useTaskStore = defineStore('tasks', () => {
|
||||||
|
const tasks = ref<Task[]>([])
|
||||||
|
const ready = ref(false)
|
||||||
|
/** 数据是否真正落盘;false 表示当前处于内存降级模式 */
|
||||||
|
const persistent = ref(false)
|
||||||
|
/** 需要向用户展示的存储异常提示 */
|
||||||
|
const notice = ref<string | null>(null)
|
||||||
|
/** 可用的启动快照,按时间倒序 */
|
||||||
|
const snapshots = ref<SnapshotInfo[]>([])
|
||||||
|
|
||||||
|
let repository: TaskRepository | null = null
|
||||||
|
let channel: BroadcastChannel | null = null
|
||||||
|
|
||||||
|
const byColumn = computed(() => groupByColumn(tasks.value))
|
||||||
|
|
||||||
|
const counts = computed(() => {
|
||||||
|
const result = {} as Record<ColumnId, number>
|
||||||
|
for (const columnId of Object.keys(byColumn.value) as ColumnId[]) {
|
||||||
|
result[columnId] = byColumn.value[columnId].length
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
})
|
||||||
|
|
||||||
|
function persist(): void {
|
||||||
|
if (!repository) return
|
||||||
|
try {
|
||||||
|
repository.save(tasks.value)
|
||||||
|
} catch (error) {
|
||||||
|
notice.value = error instanceof Error ? error.message : '保存失败'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function broadcastChange(): void {
|
||||||
|
channel?.postMessage({ type: 'tasks-changed' } satisfies SyncMessage)
|
||||||
|
}
|
||||||
|
|
||||||
|
function commit(): void {
|
||||||
|
persist()
|
||||||
|
broadcastChange()
|
||||||
|
}
|
||||||
|
|
||||||
|
function reloadFromRepository(): void {
|
||||||
|
if (!repository) return
|
||||||
|
try {
|
||||||
|
tasks.value = sortByOrder(repository.load())
|
||||||
|
} catch {
|
||||||
|
// 另一标签页可能正在写入,读取失败时保留当前内存态即可
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshSnapshots(): void {
|
||||||
|
if (!repository) return
|
||||||
|
try {
|
||||||
|
snapshots.value = repository.snapshots()
|
||||||
|
} catch {
|
||||||
|
snapshots.value = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 启动时留一份快照。
|
||||||
|
*
|
||||||
|
* 注意:即使看板为空也要刷新快照列表 —— 数据刚丢失时正是最需要
|
||||||
|
* 看到历史备份的时刻,此时若因为「空看板不写快照」而连带跳过刷新,
|
||||||
|
* 用户就彻底没有恢复入口了。
|
||||||
|
*/
|
||||||
|
function writeStartupSnapshot(): void {
|
||||||
|
if (!repository) return
|
||||||
|
|
||||||
|
if (persistent.value && tasks.value.length > 0) {
|
||||||
|
try {
|
||||||
|
repository.snapshot(tasks.value)
|
||||||
|
} catch {
|
||||||
|
// 备份失败不应影响正常使用
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshSnapshots()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 从快照恢复;返回实际恢复的任务数 */
|
||||||
|
function restoreSnapshot(key: string): number {
|
||||||
|
if (!repository) return 0
|
||||||
|
const restored = repository.restoreSnapshot(key)
|
||||||
|
if (restored.length === 0) return 0
|
||||||
|
tasks.value = sortByOrder(restored)
|
||||||
|
commit()
|
||||||
|
return restored.length
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupChannel(): void {
|
||||||
|
if (channel || typeof BroadcastChannel === 'undefined') return
|
||||||
|
channel = new BroadcastChannel(SYNC_CHANNEL_NAME)
|
||||||
|
channel.addEventListener('message', (event: MessageEvent) => {
|
||||||
|
if (isSyncMessage(event.data)) reloadFromRepository()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function init(options: InitOptions = {}): void {
|
||||||
|
if (ready.value) return
|
||||||
|
|
||||||
|
if (options.repository) {
|
||||||
|
repository = options.repository
|
||||||
|
persistent.value = true
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
repository = createLocalStorageTaskRepository()
|
||||||
|
persistent.value = true
|
||||||
|
} catch (error) {
|
||||||
|
repository = createMemoryTaskRepository()
|
||||||
|
persistent.value = false
|
||||||
|
notice.value =
|
||||||
|
error instanceof Error ? error.message : '本地存储不可用,本次改动不会被保存'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
tasks.value = sortByOrder(repository.load())
|
||||||
|
} catch (error) {
|
||||||
|
tasks.value = []
|
||||||
|
notice.value = error instanceof Error ? error.message : '本地数据读取失败'
|
||||||
|
}
|
||||||
|
|
||||||
|
ready.value = true
|
||||||
|
|
||||||
|
writeStartupSnapshot()
|
||||||
|
|
||||||
|
if (options.crossTab !== false) setupChannel()
|
||||||
|
}
|
||||||
|
|
||||||
|
function createTask(input: CreateTaskInput): Task | null {
|
||||||
|
const title = input.title.trim()
|
||||||
|
if (!title) return null
|
||||||
|
|
||||||
|
const status = input.status ?? 'todo'
|
||||||
|
const now = new Date().toISOString()
|
||||||
|
const task: Task = {
|
||||||
|
id: createId(),
|
||||||
|
title,
|
||||||
|
description: input.description?.trim() ?? '',
|
||||||
|
status,
|
||||||
|
priority: input.priority ?? DEFAULT_PRIORITY,
|
||||||
|
tags: normalizeTags(input.tags ?? []),
|
||||||
|
dueDate: input.dueDate ?? null,
|
||||||
|
// 快速新增插入列首
|
||||||
|
sortOrder: sortOrderForPosition(groupByColumn(tasks.value)[status], 0),
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
completedAt: status === 'done' ? now : null,
|
||||||
|
archivedAt: null,
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.value = [...tasks.value, task]
|
||||||
|
commit()
|
||||||
|
return task
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateTask(id: string, patch: TaskPatch): Task | null {
|
||||||
|
const index = tasks.value.findIndex((task) => task.id === id)
|
||||||
|
const current = tasks.value[index]
|
||||||
|
if (!current) return null
|
||||||
|
|
||||||
|
const now = new Date().toISOString()
|
||||||
|
const nextStatus = patch.status ?? current.status
|
||||||
|
|
||||||
|
let completedAt = current.completedAt
|
||||||
|
if (patch.completedAt !== undefined) {
|
||||||
|
completedAt = patch.completedAt
|
||||||
|
} else if (nextStatus === 'done' && current.status !== 'done') {
|
||||||
|
completedAt = now
|
||||||
|
} else if (nextStatus !== 'done' && current.status === 'done') {
|
||||||
|
completedAt = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// 状态变更且未显式指定顺序时,追加到目标列末尾
|
||||||
|
let sortOrder = patch.sortOrder ?? current.sortOrder
|
||||||
|
if (nextStatus !== current.status && patch.sortOrder === undefined) {
|
||||||
|
const target = groupByColumn(tasks.value)[nextStatus].filter((task) => task.id !== id)
|
||||||
|
sortOrder = sortOrderForPosition(target, target.length)
|
||||||
|
}
|
||||||
|
|
||||||
|
const title = patch.title === undefined ? current.title : patch.title.trim() || current.title
|
||||||
|
|
||||||
|
const updated: Task = {
|
||||||
|
...current,
|
||||||
|
...patch,
|
||||||
|
title,
|
||||||
|
status: nextStatus,
|
||||||
|
sortOrder,
|
||||||
|
tags: patch.tags ? normalizeTags(patch.tags) : current.tags,
|
||||||
|
completedAt,
|
||||||
|
updatedAt: now,
|
||||||
|
}
|
||||||
|
|
||||||
|
const next = [...tasks.value]
|
||||||
|
next[index] = updated
|
||||||
|
tasks.value = next
|
||||||
|
commit()
|
||||||
|
return updated
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 删除任务,返回被删除的任务本身,供撤销使用(T12) */
|
||||||
|
function removeTask(id: string): Task | null {
|
||||||
|
const task = tasks.value.find((item) => item.id === id)
|
||||||
|
if (!task) return null
|
||||||
|
tasks.value = tasks.value.filter((item) => item.id !== id)
|
||||||
|
commit()
|
||||||
|
return task
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 撤销删除:连原 sortOrder 一起恢复,因此位置也会还原 */
|
||||||
|
function restoreTask(task: Task): void {
|
||||||
|
if (tasks.value.some((item) => item.id === task.id)) return
|
||||||
|
tasks.value = [...tasks.value, task]
|
||||||
|
commit()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 拖拽移动(T13)。toIndex 为移动完成后在目标列中的最终下标。
|
||||||
|
* status 与 sortOrder 在同一次赋值中更新,不存在中间态。
|
||||||
|
*/
|
||||||
|
function moveTask(id: string, toStatus: ColumnId, toIndex: number): void {
|
||||||
|
const task = tasks.value.find((item) => item.id === id)
|
||||||
|
if (!task) return
|
||||||
|
|
||||||
|
const now = new Date().toISOString()
|
||||||
|
|
||||||
|
if (task.status === toStatus) {
|
||||||
|
const column = groupByColumn(tasks.value)[toStatus]
|
||||||
|
const fromIndex = column.findIndex((item) => item.id === id)
|
||||||
|
if (fromIndex === -1) return
|
||||||
|
const moved = moveWithinColumn(column, fromIndex, toIndex)
|
||||||
|
const byId = new Map(moved.map((item) => [item.id, item]))
|
||||||
|
tasks.value = tasks.value.map((item) => byId.get(item.id) ?? item)
|
||||||
|
} else {
|
||||||
|
const target = groupByColumn(tasks.value)[toStatus].filter((item) => item.id !== id)
|
||||||
|
const sortOrder = sortOrderForPosition(target, toIndex)
|
||||||
|
tasks.value = tasks.value.map((item) =>
|
||||||
|
item.id === id
|
||||||
|
? {
|
||||||
|
...item,
|
||||||
|
status: toStatus,
|
||||||
|
sortOrder,
|
||||||
|
completedAt: toStatus === 'done' ? now : null,
|
||||||
|
updatedAt: now,
|
||||||
|
}
|
||||||
|
: item,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
commit()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 导出为可读 JSON(T14) */
|
||||||
|
function exportJson(): string {
|
||||||
|
return serializeState(tasks.value, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 覆盖式导入(T14)。校验失败会抛错,此时现有数据保持不变。
|
||||||
|
* @returns 成功导入的任务数量
|
||||||
|
*/
|
||||||
|
function importJson(raw: string): number {
|
||||||
|
const imported = parseImportJson(raw)
|
||||||
|
tasks.value = sortByOrder(imported)
|
||||||
|
commit()
|
||||||
|
return imported.length
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 覆盖全部数据(供导入后的自定义处理或测试使用) */
|
||||||
|
function replaceAll(next: readonly Task[]): void {
|
||||||
|
tasks.value = sortByOrder(next)
|
||||||
|
commit()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 归档指定任务(T17)。返回实际归档的数量。
|
||||||
|
* 归档只改 archivedAt,数据完整保留,可随时撤销。
|
||||||
|
*/
|
||||||
|
function archiveTasks(ids: readonly string[]): number {
|
||||||
|
const idSet = new Set(ids)
|
||||||
|
const now = new Date().toISOString()
|
||||||
|
let count = 0
|
||||||
|
|
||||||
|
tasks.value = tasks.value.map((task) => {
|
||||||
|
if (!idSet.has(task.id) || task.archivedAt !== null) return task
|
||||||
|
count += 1
|
||||||
|
return { ...task, archivedAt: now, updatedAt: now }
|
||||||
|
})
|
||||||
|
|
||||||
|
if (count > 0) commit()
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 取消归档;返回实际恢复的数量 */
|
||||||
|
function unarchiveTasks(ids: readonly string[]): number {
|
||||||
|
const idSet = new Set(ids)
|
||||||
|
const now = new Date().toISOString()
|
||||||
|
let count = 0
|
||||||
|
|
||||||
|
tasks.value = tasks.value.map((task) => {
|
||||||
|
if (!idSet.has(task.id) || task.archivedAt === null) return task
|
||||||
|
count += 1
|
||||||
|
return { ...task, archivedAt: null, updatedAt: now }
|
||||||
|
})
|
||||||
|
|
||||||
|
if (count > 0) commit()
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 释放跨标签页频道;主要供测试使用 */
|
||||||
|
function dispose(): void {
|
||||||
|
channel?.close()
|
||||||
|
channel = null
|
||||||
|
repository = null
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
tasks,
|
||||||
|
ready,
|
||||||
|
persistent,
|
||||||
|
notice,
|
||||||
|
snapshots,
|
||||||
|
byColumn,
|
||||||
|
counts,
|
||||||
|
init,
|
||||||
|
createTask,
|
||||||
|
updateTask,
|
||||||
|
removeTask,
|
||||||
|
restoreTask,
|
||||||
|
moveTask,
|
||||||
|
exportJson,
|
||||||
|
importJson,
|
||||||
|
replaceAll,
|
||||||
|
archiveTasks,
|
||||||
|
unarchiveTasks,
|
||||||
|
refreshSnapshots,
|
||||||
|
restoreSnapshot,
|
||||||
|
dispose,
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import { createPinia, setActivePinia } from 'pinia'
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
import { DEFAULT_TOAST_DURATION, useToastStore } from './toast'
|
||||||
|
|
||||||
|
describe('toast store', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
setActivePinia(createPinia())
|
||||||
|
vi.useFakeTimers()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('show 添加一条提示并返回 id', () => {
|
||||||
|
const store = useToastStore()
|
||||||
|
const id = store.show({ message: '已保存' })
|
||||||
|
|
||||||
|
expect(typeof id).toBe('string')
|
||||||
|
expect(store.toasts).toHaveLength(1)
|
||||||
|
expect(store.toasts[0]?.message).toBe('已保存')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('默认使用默认时长', () => {
|
||||||
|
const store = useToastStore()
|
||||||
|
store.show({ message: 'x' })
|
||||||
|
expect(store.toasts[0]?.duration).toBe(DEFAULT_TOAST_DURATION)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('dismiss 移除指定提示', () => {
|
||||||
|
const store = useToastStore()
|
||||||
|
const first = store.show({ message: 'A', duration: 0 })
|
||||||
|
store.show({ message: 'B', duration: 0 })
|
||||||
|
|
||||||
|
store.dismiss(first)
|
||||||
|
|
||||||
|
expect(store.toasts.map((toast) => toast.message)).toEqual(['B'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('到期后自动关闭', () => {
|
||||||
|
const store = useToastStore()
|
||||||
|
store.show({ message: '自动关闭', duration: 1000 })
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(999)
|
||||||
|
expect(store.toasts).toHaveLength(1)
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(1)
|
||||||
|
expect(store.toasts).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('duration 为 0 时不自动关闭', () => {
|
||||||
|
const store = useToastStore()
|
||||||
|
store.show({ message: '常驻', duration: 0 })
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(60_000)
|
||||||
|
expect(store.toasts).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('runAction 先执行动作再关闭提示', () => {
|
||||||
|
const store = useToastStore()
|
||||||
|
const run = vi.fn()
|
||||||
|
const id = store.show({ message: '已删除', action: { label: '撤销', run }, duration: 0 })
|
||||||
|
|
||||||
|
store.runAction(id)
|
||||||
|
|
||||||
|
expect(run).toHaveBeenCalledTimes(1)
|
||||||
|
expect(store.toasts).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('runAction 对不存在的 id 不抛错', () => {
|
||||||
|
const store = useToastStore()
|
||||||
|
expect(() => store.runAction('missing')).not.toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('clear 清空全部提示并取消自动关闭', () => {
|
||||||
|
const store = useToastStore()
|
||||||
|
store.show({ message: 'A', duration: 1000 })
|
||||||
|
store.show({ message: 'B', duration: 1000 })
|
||||||
|
|
||||||
|
store.clear()
|
||||||
|
|
||||||
|
expect(store.toasts).toHaveLength(0)
|
||||||
|
vi.advanceTimersByTime(5000)
|
||||||
|
expect(store.toasts).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('多条提示按加入顺序排列', () => {
|
||||||
|
const store = useToastStore()
|
||||||
|
store.show({ message: 'A', duration: 0 })
|
||||||
|
store.show({ message: 'B', duration: 0 })
|
||||||
|
expect(store.toasts.map((toast) => toast.message)).toEqual(['A', 'B'])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
/**
|
||||||
|
* 轻量提示(Toast)状态(T09)
|
||||||
|
*
|
||||||
|
* 支持携带一个动作按钮,用于「删除后撤销」(T12)。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
import { createId } from '@/utils/id'
|
||||||
|
|
||||||
|
export interface ToastAction {
|
||||||
|
label: string
|
||||||
|
run: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ToastOptions {
|
||||||
|
message: string
|
||||||
|
action?: ToastAction
|
||||||
|
/** 毫秒;0 表示不自动关闭 */
|
||||||
|
duration?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ToastItem {
|
||||||
|
id: string
|
||||||
|
message: string
|
||||||
|
action?: ToastAction
|
||||||
|
duration: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_TOAST_DURATION = 5000
|
||||||
|
|
||||||
|
export const useToastStore = defineStore('toast', () => {
|
||||||
|
const toasts = ref<ToastItem[]>([])
|
||||||
|
const timers = new Map<string, ReturnType<typeof setTimeout>>()
|
||||||
|
|
||||||
|
function clearTimer(id: string): void {
|
||||||
|
const timer = timers.get(id)
|
||||||
|
if (timer !== undefined) {
|
||||||
|
clearTimeout(timer)
|
||||||
|
timers.delete(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function dismiss(id: string): void {
|
||||||
|
clearTimer(id)
|
||||||
|
toasts.value = toasts.value.filter((toast) => toast.id !== id)
|
||||||
|
}
|
||||||
|
|
||||||
|
function show(options: ToastOptions): string {
|
||||||
|
const id = createId()
|
||||||
|
const duration = options.duration ?? DEFAULT_TOAST_DURATION
|
||||||
|
|
||||||
|
const item: ToastItem = { id, message: options.message, duration }
|
||||||
|
if (options.action) item.action = options.action
|
||||||
|
|
||||||
|
toasts.value = [...toasts.value, item]
|
||||||
|
|
||||||
|
if (duration > 0) {
|
||||||
|
timers.set(
|
||||||
|
id,
|
||||||
|
setTimeout(() => dismiss(id), duration),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 执行提示上的动作,并立即关闭该提示 */
|
||||||
|
function runAction(id: string): void {
|
||||||
|
const toast = toasts.value.find((item) => item.id === id)
|
||||||
|
dismiss(id)
|
||||||
|
toast?.action?.run()
|
||||||
|
}
|
||||||
|
|
||||||
|
function clear(): void {
|
||||||
|
for (const id of [...timers.keys()]) clearTimer(id)
|
||||||
|
toasts.value = []
|
||||||
|
}
|
||||||
|
|
||||||
|
return { toasts, show, dismiss, runAction, clear }
|
||||||
|
})
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
@import 'tailwindcss';
|
||||||
|
|
||||||
|
@theme {
|
||||||
|
--font-sans:
|
||||||
|
system-ui, 'Segoe UI', 'Microsoft YaHei', 'PingFang SC', Roboto, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body,
|
||||||
|
#app {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
import { COLUMNS, COLUMN_IDS, PRIORITY_LABELS, isColumnId, isPriority } from './task'
|
||||||
|
|
||||||
|
describe('列定义', () => {
|
||||||
|
it('固定三列,顺序为 待办 / 进行中 / 已完成', () => {
|
||||||
|
expect(COLUMNS.map((column) => column.id)).toEqual(['todo', 'doing', 'done'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('COLUMN_IDS 与 COLUMNS 完全一致', () => {
|
||||||
|
expect([...COLUMN_IDS]).toEqual(COLUMNS.map((column) => column.id))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('列定义为只读常量,不包含重复 id', () => {
|
||||||
|
expect(new Set(COLUMN_IDS).size).toBe(COLUMN_IDS.length)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('优先级', () => {
|
||||||
|
it('三个优先级都有中文标签', () => {
|
||||||
|
expect(PRIORITY_LABELS[1]).toBe('低')
|
||||||
|
expect(PRIORITY_LABELS[2]).toBe('中')
|
||||||
|
expect(PRIORITY_LABELS[3]).toBe('高')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('isPriority 只接受 1/2/3', () => {
|
||||||
|
expect(isPriority(1)).toBe(true)
|
||||||
|
expect(isPriority(3)).toBe(true)
|
||||||
|
expect(isPriority(0)).toBe(false)
|
||||||
|
expect(isPriority(4)).toBe(false)
|
||||||
|
expect(isPriority('2')).toBe(false)
|
||||||
|
expect(isPriority(null)).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('isColumnId', () => {
|
||||||
|
it('接受合法列 id', () => {
|
||||||
|
expect(isColumnId('todo')).toBe(true)
|
||||||
|
expect(isColumnId('done')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('拒绝非法值', () => {
|
||||||
|
expect(isColumnId('archived')).toBe(false)
|
||||||
|
expect(isColumnId(undefined)).toBe(false)
|
||||||
|
expect(isColumnId(1)).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
/**
|
||||||
|
* 领域类型与常量(T03)
|
||||||
|
*
|
||||||
|
* 设计约定(见 docs/adr.md):
|
||||||
|
* - 列固定为三列,不使用可配置列,因此 status 直接用字面量联合类型而非 enum
|
||||||
|
* - dueDate 仅表示「日期」,格式 YYYY-MM-DD,不含时刻,以此规避时区问题
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const COLUMN_IDS = ['todo', 'doing', 'done'] as const
|
||||||
|
|
||||||
|
export type ColumnId = (typeof COLUMN_IDS)[number]
|
||||||
|
|
||||||
|
/** 优先级:1 = 低,2 = 中,3 = 高 */
|
||||||
|
export type Priority = 1 | 2 | 3
|
||||||
|
|
||||||
|
export interface Task {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
status: ColumnId
|
||||||
|
priority: Priority
|
||||||
|
tags: string[]
|
||||||
|
/** 仅日期,格式 YYYY-MM-DD;无截止日期时为 null */
|
||||||
|
dueDate: string | null
|
||||||
|
/** 同列内排序用的浮点数,采用相邻中点插入 */
|
||||||
|
sortOrder: number
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
completedAt: string | null
|
||||||
|
/** 归档时间;null 表示未归档。归档任务默认不在看板上显示 */
|
||||||
|
archivedAt: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ColumnDef {
|
||||||
|
readonly id: ColumnId
|
||||||
|
readonly title: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 看板列定义,顺序即展示顺序 */
|
||||||
|
export const COLUMNS: readonly ColumnDef[] = [
|
||||||
|
{ id: 'todo', title: '待办' },
|
||||||
|
{ id: 'doing', title: '进行中' },
|
||||||
|
{ id: 'done', title: '已完成' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export const PRIORITY_LABELS: Readonly<Record<Priority, string>> = {
|
||||||
|
1: '低',
|
||||||
|
2: '中',
|
||||||
|
3: '高',
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 新建任务时的默认值 */
|
||||||
|
export const DEFAULT_PRIORITY: Priority = 2
|
||||||
|
|
||||||
|
/** 判断某个值是否为合法的列 id */
|
||||||
|
export function isColumnId(value: unknown): value is ColumnId {
|
||||||
|
return typeof value === 'string' && (COLUMN_IDS as readonly string[]).includes(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 判断某个值是否为合法的优先级 */
|
||||||
|
export function isPriority(value: unknown): value is Priority {
|
||||||
|
return value === 1 || value === 2 || value === 3
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
import type { Task } from '@/types/task'
|
||||||
|
|
||||||
|
import { DEFAULT_ARCHIVE_AFTER_DAYS, applyArchiveVisibility, findArchivableTasks } from './archive'
|
||||||
|
|
||||||
|
const NOW = new Date('2026-10-18T12:00:00.000Z')
|
||||||
|
const DAY_MS = 24 * 60 * 60 * 1000
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用毫秒运算构造「N 毫秒前」的时间点。
|
||||||
|
* 不能用 dayjs().subtract(7.01, 'day'):dayjs 会把小数天数四舍五入,
|
||||||
|
* 结果变成整整 7 天前,边界用例就失去意义了。
|
||||||
|
*/
|
||||||
|
function ago(ms: number): string {
|
||||||
|
return new Date(NOW.getTime() - ms).toISOString()
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeTask(overrides: Partial<Task> = {}): Task {
|
||||||
|
return {
|
||||||
|
id: 'task-1',
|
||||||
|
title: '任务',
|
||||||
|
description: '',
|
||||||
|
status: 'done',
|
||||||
|
priority: 2,
|
||||||
|
tags: [],
|
||||||
|
dueDate: null,
|
||||||
|
sortOrder: 1024,
|
||||||
|
createdAt: '2026-10-01T00:00:00.000Z',
|
||||||
|
updatedAt: '2026-10-01T00:00:00.000Z',
|
||||||
|
completedAt: ago(10 * DAY_MS),
|
||||||
|
archivedAt: null,
|
||||||
|
...overrides,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('findArchivableTasks', () => {
|
||||||
|
it('已完成且超过 N 天的任务可归档', () => {
|
||||||
|
const task = makeTask({ completedAt: ago(10 * DAY_MS) })
|
||||||
|
expect(findArchivableTasks([task], 7, NOW)).toEqual([task])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('完成时间在 N 天以内的不归档', () => {
|
||||||
|
expect(findArchivableTasks([makeTask({ completedAt: ago(3 * DAY_MS) })], 7, NOW)).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('未完成的任务不归档', () => {
|
||||||
|
const task = makeTask({ status: 'doing', completedAt: null })
|
||||||
|
expect(findArchivableTasks([task], 7, NOW)).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('缺少 completedAt 的已完成任务不归档', () => {
|
||||||
|
expect(findArchivableTasks([makeTask({ completedAt: null })], 7, NOW)).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('已归档的任务不会重复归档', () => {
|
||||||
|
const task = makeTask({ completedAt: ago(30 * DAY_MS), archivedAt: ago(DAY_MS) })
|
||||||
|
expect(findArchivableTasks([task], 7, NOW)).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('days = 0 时所有已完成且未归档的任务都可归档', () => {
|
||||||
|
const justNow = makeTask({ id: 'just-now', completedAt: ago(12 * 60 * 60 * 1000) })
|
||||||
|
expect(findArchivableTasks([justNow], 0, NOW)).toEqual([justNow])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('恰好等于阈值时间点的任务不归档(isBefore 是严格早于)', () => {
|
||||||
|
const exactly = makeTask({ completedAt: ago(7 * DAY_MS) })
|
||||||
|
expect(findArchivableTasks([exactly], 7, NOW)).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('比阈值早一分钟即可归档', () => {
|
||||||
|
const slightlyEarlier = makeTask({ completedAt: ago(7 * DAY_MS + 60_000) })
|
||||||
|
expect(findArchivableTasks([slightlyEarlier], 7, NOW)).toEqual([slightlyEarlier])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('默认阈值为 DEFAULT_ARCHIVE_AFTER_DAYS 天', () => {
|
||||||
|
const old = makeTask({ id: 'old', completedAt: ago((DEFAULT_ARCHIVE_AFTER_DAYS + 1) * DAY_MS) })
|
||||||
|
const fresh = makeTask({
|
||||||
|
id: 'fresh',
|
||||||
|
completedAt: ago((DEFAULT_ARCHIVE_AFTER_DAYS - 1) * DAY_MS),
|
||||||
|
})
|
||||||
|
expect(findArchivableTasks([old, fresh], undefined, NOW).map((task) => task.id)).toEqual(['old'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('只挑出符合条件的任务,其余原样保留在入参中', () => {
|
||||||
|
const archivable = makeTask({ id: 'archivable', completedAt: ago(30 * DAY_MS) })
|
||||||
|
const fresh = makeTask({ id: 'fresh', completedAt: ago(DAY_MS) })
|
||||||
|
const todo = makeTask({ id: 'todo', status: 'todo', completedAt: null })
|
||||||
|
|
||||||
|
const result = findArchivableTasks([archivable, fresh, todo], 7, NOW)
|
||||||
|
|
||||||
|
expect(result.map((task) => task.id)).toEqual(['archivable'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('不修改入参', () => {
|
||||||
|
const tasks = [makeTask({ completedAt: ago(10 * DAY_MS) })]
|
||||||
|
findArchivableTasks(tasks, 7, NOW)
|
||||||
|
expect(tasks).toHaveLength(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('applyArchiveVisibility', () => {
|
||||||
|
const visible = makeTask({ id: 'visible' })
|
||||||
|
const archived = makeTask({ id: 'archived', archivedAt: ago(DAY_MS) })
|
||||||
|
|
||||||
|
it('默认隐藏归档任务', () => {
|
||||||
|
expect(applyArchiveVisibility([visible, archived], false).map((task) => task.id)).toEqual([
|
||||||
|
'visible',
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('开启后显示全部', () => {
|
||||||
|
expect(applyArchiveVisibility([visible, archived], true).map((task) => task.id)).toEqual([
|
||||||
|
'visible',
|
||||||
|
'archived',
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('返回新数组,不修改入参', () => {
|
||||||
|
const tasks = [visible, archived]
|
||||||
|
const result = applyArchiveVisibility(tasks, true)
|
||||||
|
expect(result).not.toBe(tasks)
|
||||||
|
expect(tasks).toHaveLength(2)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
/**
|
||||||
|
* 已完成任务归档(T17)
|
||||||
|
*
|
||||||
|
* 目的:看板的「已完成」列会无限增长,既影响观感也拖慢渲染。
|
||||||
|
* 把完成很久的任务归档,默认不显示,但数据仍保留、可随时恢复。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import dayjs from 'dayjs'
|
||||||
|
|
||||||
|
import type { Task } from '@/types/task'
|
||||||
|
|
||||||
|
/** 已完成任务默认保留多少天后可被归档 */
|
||||||
|
export const DEFAULT_ARCHIVE_AFTER_DAYS = 7
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 找出可归档的任务:已完成、尚未归档、且完成时间早于 N 天前。
|
||||||
|
* days = 0 表示「所有已完成且未归档的任务」。
|
||||||
|
*/
|
||||||
|
export function findArchivableTasks(
|
||||||
|
tasks: readonly Task[],
|
||||||
|
days: number = DEFAULT_ARCHIVE_AFTER_DAYS,
|
||||||
|
now: Date = new Date(),
|
||||||
|
): Task[] {
|
||||||
|
const threshold = dayjs(now).subtract(days, 'day')
|
||||||
|
return tasks.filter((task) => {
|
||||||
|
if (task.archivedAt !== null) return false
|
||||||
|
if (task.status !== 'done' || task.completedAt === null) return false
|
||||||
|
return dayjs(task.completedAt).isBefore(threshold)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 按是否显示归档过滤任务列表 */
|
||||||
|
export function applyArchiveVisibility(tasks: readonly Task[], includeArchived: boolean): Task[] {
|
||||||
|
if (includeArchived) return [...tasks]
|
||||||
|
return tasks.filter((task) => task.archivedAt === null)
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import dayjs from 'dayjs'
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
import { formatDueDate, isDueToday, isOverdue, todayIso } from './date'
|
||||||
|
|
||||||
|
const today = todayIso()
|
||||||
|
const yesterday = dayjs().subtract(1, 'day').format('YYYY-MM-DD')
|
||||||
|
const tomorrow = dayjs().add(1, 'day').format('YYYY-MM-DD')
|
||||||
|
|
||||||
|
describe('todayIso', () => {
|
||||||
|
it('返回 YYYY-MM-DD 格式', () => {
|
||||||
|
expect(todayIso()).toMatch(/^\d{4}-\d{2}-\d{2}$/)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('isOverdue', () => {
|
||||||
|
it('没有截止日期时不算逾期', () => {
|
||||||
|
expect(isOverdue(null, 'todo')).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('截止日期早于今天且未完成时算逾期', () => {
|
||||||
|
expect(isOverdue(yesterday, 'todo')).toBe(true)
|
||||||
|
expect(isOverdue(yesterday, 'doing')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('已完成的任务不再算逾期', () => {
|
||||||
|
expect(isOverdue(yesterday, 'done')).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('今天与未来都不算逾期', () => {
|
||||||
|
expect(isOverdue(today, 'todo')).toBe(false)
|
||||||
|
expect(isOverdue(tomorrow, 'todo')).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('isDueToday', () => {
|
||||||
|
it('仅当截止日期为今天且未完成时为真', () => {
|
||||||
|
expect(isDueToday(today, 'todo')).toBe(true)
|
||||||
|
expect(isDueToday(today, 'done')).toBe(false)
|
||||||
|
expect(isDueToday(yesterday, 'todo')).toBe(false)
|
||||||
|
expect(isDueToday(null, 'todo')).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('formatDueDate', () => {
|
||||||
|
const base = '2026-10-18'
|
||||||
|
|
||||||
|
it('相对日期使用自然语言', () => {
|
||||||
|
expect(formatDueDate('2026-10-18', base)).toBe('今天')
|
||||||
|
expect(formatDueDate('2026-10-19', base)).toBe('明天')
|
||||||
|
expect(formatDueDate('2026-10-20', base)).toBe('后天')
|
||||||
|
expect(formatDueDate('2026-10-21', base)).toBe('3 天后')
|
||||||
|
expect(formatDueDate('2026-10-25', base)).toBe('7 天后')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('超过一周显示具体日期', () => {
|
||||||
|
expect(formatDueDate('2026-10-26', base)).toBe('10月26日')
|
||||||
|
expect(formatDueDate('2026-12-01', base)).toBe('12月1日')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('过去的日期显示逾期天数', () => {
|
||||||
|
expect(formatDueDate('2026-10-17', base)).toBe('逾期 1 天')
|
||||||
|
expect(formatDueDate('2026-10-01', base)).toBe('逾期 17 天')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('跨月计算正确', () => {
|
||||||
|
expect(formatDueDate('2026-11-01', '2026-10-31')).toBe('明天')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
/**
|
||||||
|
* 日期工具(T08 / T16)
|
||||||
|
*
|
||||||
|
* 依据 ADR-003,dueDate 只表示「日期」(YYYY-MM-DD),不含时刻与时区信息。
|
||||||
|
* 因此「是否逾期」直接比较字符串即可,无需任何时区换算。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import dayjs from 'dayjs'
|
||||||
|
|
||||||
|
import type { ColumnId } from '@/types/task'
|
||||||
|
|
||||||
|
/** 今天的本地日期,格式 YYYY-MM-DD */
|
||||||
|
export function todayIso(): string {
|
||||||
|
return dayjs().format('YYYY-MM-DD')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 逾期:截止日期早于今天,且任务尚未完成。today 参数用于测试注入 */
|
||||||
|
export function isOverdue(
|
||||||
|
dueDate: string | null,
|
||||||
|
status: ColumnId,
|
||||||
|
today: string = todayIso(),
|
||||||
|
): boolean {
|
||||||
|
if (!dueDate || status === 'done') return false
|
||||||
|
return dueDate < today
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 今天到期:截止日期正是今天,且任务尚未完成。today 参数用于测试注入 */
|
||||||
|
export function isDueToday(
|
||||||
|
dueDate: string | null,
|
||||||
|
status: ColumnId,
|
||||||
|
today: string = todayIso(),
|
||||||
|
): boolean {
|
||||||
|
if (!dueDate || status === 'done') return false
|
||||||
|
return dueDate === today
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 人类可读的截止日期文案。
|
||||||
|
* today 参数用于测试注入,业务代码不应传入。
|
||||||
|
*/
|
||||||
|
export function formatDueDate(dueDate: string, today: string = todayIso()): string {
|
||||||
|
const diff = dayjs(dueDate).startOf('day').diff(dayjs(today).startOf('day'), 'day')
|
||||||
|
|
||||||
|
if (diff < 0) return `逾期 ${Math.abs(diff)} 天`
|
||||||
|
if (diff === 0) return '今天'
|
||||||
|
if (diff === 1) return '明天'
|
||||||
|
if (diff === 2) return '后天'
|
||||||
|
if (diff <= 7) return `${diff} 天后`
|
||||||
|
return dayjs(dueDate).format('M月D日')
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
import { resolveDrop, shouldAllowMove } from './drag'
|
||||||
|
|
||||||
|
function makeList(columnId?: string): HTMLElement {
|
||||||
|
const element = document.createElement('div')
|
||||||
|
if (columnId !== undefined) element.dataset.columnId = columnId
|
||||||
|
return element
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeItem(taskId?: string): HTMLElement {
|
||||||
|
const element = document.createElement('article')
|
||||||
|
if (taskId !== undefined) element.dataset.taskId = taskId
|
||||||
|
return element
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('resolveDrop', () => {
|
||||||
|
it('解析出任务 id、目标列与下标', () => {
|
||||||
|
const drop = resolveDrop({
|
||||||
|
item: makeItem('task-1'),
|
||||||
|
to: makeList('doing'),
|
||||||
|
newIndex: 2,
|
||||||
|
})
|
||||||
|
expect(drop).toEqual({ taskId: 'task-1', toColumn: 'doing', toIndex: 2 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('下标为 0 是合法值', () => {
|
||||||
|
expect(resolveDrop({ item: makeItem('t'), to: makeList('todo'), newIndex: 0 })).toEqual({
|
||||||
|
taskId: 't',
|
||||||
|
toColumn: 'todo',
|
||||||
|
toIndex: 0,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('缺少 taskId 时返回 null', () => {
|
||||||
|
expect(resolveDrop({ item: makeItem(), to: makeList('todo'), newIndex: 0 })).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('目标列 id 非法时返回 null', () => {
|
||||||
|
expect(
|
||||||
|
resolveDrop({ item: makeItem('t'), to: makeList('archived'), newIndex: 0 }),
|
||||||
|
).toBeNull()
|
||||||
|
expect(resolveDrop({ item: makeItem('t'), to: makeList(), newIndex: 0 })).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('下标缺失或非法时返回 null', () => {
|
||||||
|
const item = makeItem('t')
|
||||||
|
const to = makeList('todo')
|
||||||
|
expect(resolveDrop({ item, to })).toBeNull()
|
||||||
|
expect(resolveDrop({ item, to, newIndex: -1 })).toBeNull()
|
||||||
|
expect(resolveDrop({ item, to, newIndex: 1.5 })).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('shouldAllowMove', () => {
|
||||||
|
it('列内为全量渲染时一律允许', () => {
|
||||||
|
const a = makeList('todo')
|
||||||
|
const b = makeList('doing')
|
||||||
|
expect(shouldAllowMove(a, a, false)).toBe(true)
|
||||||
|
expect(shouldAllowMove(a, b, false)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('列内为子集时禁止列内排序', () => {
|
||||||
|
const column = makeList('todo')
|
||||||
|
expect(shouldAllowMove(column, column, true)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('列内为子集时仍允许跨列移动', () => {
|
||||||
|
expect(shouldAllowMove(makeList('todo'), makeList('doing'), true)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('子集状态下元素信息缺失时保守拒绝', () => {
|
||||||
|
expect(shouldAllowMove(null, makeList('todo'), true)).toBe(false)
|
||||||
|
expect(shouldAllowMove(makeList('todo'), undefined, true)).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
/**
|
||||||
|
* 拖拽落点解析(T13)
|
||||||
|
*
|
||||||
|
* 把 SortableJS 的原始事件收敛为领域概念,并在这里承载一条关键规则:
|
||||||
|
* **筛选/搜索激活时禁止列内排序**。
|
||||||
|
*
|
||||||
|
* 原因:筛选后列内只渲染子集,而 Sortable 给出的 newIndex 是「子集内下标」,
|
||||||
|
* 直接写回会把全量顺序改坏。此时只允许跨列改状态。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { isColumnId, type ColumnId } from '@/types/task'
|
||||||
|
|
||||||
|
export interface DropDescriptor {
|
||||||
|
taskId: string
|
||||||
|
toColumn: ColumnId
|
||||||
|
toIndex: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DropEventLike {
|
||||||
|
item: HTMLElement
|
||||||
|
to: HTMLElement
|
||||||
|
newIndex?: number | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 从 onEnd 事件解析出「哪个任务落到了哪一列的哪个位置」;信息不全时返回 null */
|
||||||
|
export function resolveDrop(event: DropEventLike): DropDescriptor | null {
|
||||||
|
const taskId = event.item.dataset.taskId
|
||||||
|
const rawColumn = event.to.dataset.columnId
|
||||||
|
const index = event.newIndex
|
||||||
|
|
||||||
|
if (!taskId || !rawColumn || !isColumnId(rawColumn)) return null
|
||||||
|
if (typeof index !== 'number' || !Number.isInteger(index) || index < 0) return null
|
||||||
|
|
||||||
|
return { taskId, toColumn: rawColumn, toIndex: index }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 是否允许这次移动。
|
||||||
|
*
|
||||||
|
* sortDisabled 表示「当前列渲染的是子集」(存在筛选,或隐藏了归档任务)。
|
||||||
|
* 此时只允许跨列移动,禁止列内排序 —— 因为 Sortable 给出的 newIndex 是子集内下标,
|
||||||
|
* 直接写回会把全量顺序改坏。
|
||||||
|
*/
|
||||||
|
export function shouldAllowMove(
|
||||||
|
from: HTMLElement | null | undefined,
|
||||||
|
to: HTMLElement | null | undefined,
|
||||||
|
sortDisabled: boolean,
|
||||||
|
): boolean {
|
||||||
|
if (!sortDisabled) return true
|
||||||
|
if (!from || !to) return false
|
||||||
|
return from !== to
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
import { downloadText, exportFileName } from './exportFile'
|
||||||
|
|
||||||
|
const originalCreate = Object.getOwnPropertyDescriptor(URL, 'createObjectURL')
|
||||||
|
const originalRevoke = Object.getOwnPropertyDescriptor(URL, 'revokeObjectURL')
|
||||||
|
const originalClick = HTMLAnchorElement.prototype.click
|
||||||
|
|
||||||
|
function restore(): void {
|
||||||
|
if (originalCreate) Object.defineProperty(URL, 'createObjectURL', originalCreate)
|
||||||
|
else Reflect.deleteProperty(URL, 'createObjectURL')
|
||||||
|
|
||||||
|
if (originalRevoke) Object.defineProperty(URL, 'revokeObjectURL', originalRevoke)
|
||||||
|
else Reflect.deleteProperty(URL, 'revokeObjectURL')
|
||||||
|
|
||||||
|
HTMLAnchorElement.prototype.click = originalClick
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(restore)
|
||||||
|
|
||||||
|
describe('exportFileName', () => {
|
||||||
|
it('包含日期,格式为 kanban-YYYY-MM-DD.json', () => {
|
||||||
|
expect(exportFileName('2026-10-18')).toBe('kanban-2026-10-18.json')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('默认使用今天', () => {
|
||||||
|
expect(exportFileName()).toMatch(/^kanban-\d{4}-\d{2}-\d{2}\.json$/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('不同日期产生不同文件名', () => {
|
||||||
|
expect(exportFileName('2026-10-18')).not.toBe(exportFileName('2026-10-19'))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('扩展名是 .json', () => {
|
||||||
|
expect(exportFileName('2026-01-01').endsWith('.json')).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('downloadText', () => {
|
||||||
|
it('支持 createObjectURL 时创建链接、点击并释放 URL', () => {
|
||||||
|
const createObjectURL = vi.fn(() => 'blob:mock-url')
|
||||||
|
const revokeObjectURL = vi.fn()
|
||||||
|
Object.defineProperty(URL, 'createObjectURL', {
|
||||||
|
value: createObjectURL,
|
||||||
|
configurable: true,
|
||||||
|
writable: true,
|
||||||
|
})
|
||||||
|
Object.defineProperty(URL, 'revokeObjectURL', {
|
||||||
|
value: revokeObjectURL,
|
||||||
|
configurable: true,
|
||||||
|
writable: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
const downloaded: string[] = []
|
||||||
|
HTMLAnchorElement.prototype.click = function click(this: HTMLAnchorElement) {
|
||||||
|
downloaded.push(this.download)
|
||||||
|
}
|
||||||
|
|
||||||
|
downloadText('{"a":1}', 'kanban-2026-10-18.json')
|
||||||
|
|
||||||
|
expect(createObjectURL).toHaveBeenCalledTimes(1)
|
||||||
|
expect(downloaded).toEqual(['kanban-2026-10-18.json'])
|
||||||
|
expect(revokeObjectURL).toHaveBeenCalledWith('blob:mock-url')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('点击后不残留链接节点', () => {
|
||||||
|
Object.defineProperty(URL, 'createObjectURL', {
|
||||||
|
value: vi.fn(() => 'blob:mock-url'),
|
||||||
|
configurable: true,
|
||||||
|
writable: true,
|
||||||
|
})
|
||||||
|
Object.defineProperty(URL, 'revokeObjectURL', {
|
||||||
|
value: vi.fn(),
|
||||||
|
configurable: true,
|
||||||
|
writable: true,
|
||||||
|
})
|
||||||
|
HTMLAnchorElement.prototype.click = () => undefined
|
||||||
|
|
||||||
|
downloadText('{}', 'a.json')
|
||||||
|
|
||||||
|
expect(document.querySelectorAll('a[download]')).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('不支持 createObjectURL 时静默跳过(不抛错)', () => {
|
||||||
|
// 注意:不能 delete —— Node 环境下 URL.createObjectURL 是内置的,删不掉
|
||||||
|
Object.defineProperty(URL, 'createObjectURL', {
|
||||||
|
value: undefined,
|
||||||
|
configurable: true,
|
||||||
|
writable: true,
|
||||||
|
})
|
||||||
|
expect(() => downloadText('{}', 'a.json')).not.toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('可自定义 MIME 类型', () => {
|
||||||
|
const createObjectURL = vi.fn((_blob: Blob) => 'blob:mock-url')
|
||||||
|
Object.defineProperty(URL, 'createObjectURL', {
|
||||||
|
value: createObjectURL,
|
||||||
|
configurable: true,
|
||||||
|
writable: true,
|
||||||
|
})
|
||||||
|
Object.defineProperty(URL, 'revokeObjectURL', {
|
||||||
|
value: vi.fn(),
|
||||||
|
configurable: true,
|
||||||
|
writable: true,
|
||||||
|
})
|
||||||
|
HTMLAnchorElement.prototype.click = () => undefined
|
||||||
|
|
||||||
|
downloadText('a,b', 'a.csv', 'text/csv')
|
||||||
|
|
||||||
|
expect(createObjectURL).toHaveBeenCalledTimes(1)
|
||||||
|
const blob = createObjectURL.mock.calls[0]?.[0]
|
||||||
|
expect(blob?.type).toBe('text/csv;charset=utf-8')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
/**
|
||||||
|
* 导出文件工具(T14)
|
||||||
|
*
|
||||||
|
* 把「文件名生成」与「触发下载」分开:前者是纯函数可直接测试,
|
||||||
|
* 后者依赖浏览器 API(jsdom 不实现 URL.createObjectURL)。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { todayIso } from '@/utils/date'
|
||||||
|
|
||||||
|
/** 导出文件名,含日期便于区分多次备份 */
|
||||||
|
export function exportFileName(today: string = todayIso()): string {
|
||||||
|
return `kanban-${today}.json`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 触发浏览器下载;非浏览器环境静默跳过 */
|
||||||
|
export function downloadText(content: string, fileName: string, mimeType = 'application/json'): void {
|
||||||
|
if (typeof document === 'undefined' || typeof URL.createObjectURL !== 'function') return
|
||||||
|
|
||||||
|
const blob = new Blob([content], { type: `${mimeType};charset=utf-8` })
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const link = document.createElement('a')
|
||||||
|
link.href = url
|
||||||
|
link.download = fileName
|
||||||
|
link.rel = 'noopener'
|
||||||
|
document.body.appendChild(link)
|
||||||
|
link.click()
|
||||||
|
link.remove()
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
}
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
import dayjs from 'dayjs'
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
import type { Task } from '@/types/task'
|
||||||
|
|
||||||
|
import { EMPTY_FILTER, filterTasks, isFilterActive, matchesFilter, type TaskFilter } from './filter'
|
||||||
|
|
||||||
|
function makeTask(overrides: Partial<Task> = {}): Task {
|
||||||
|
return {
|
||||||
|
id: 'task-1',
|
||||||
|
title: '写方案',
|
||||||
|
description: '详细描述',
|
||||||
|
status: 'todo',
|
||||||
|
priority: 2,
|
||||||
|
tags: [],
|
||||||
|
dueDate: null,
|
||||||
|
sortOrder: 1024,
|
||||||
|
createdAt: '2026-10-18T00:00:00.000Z',
|
||||||
|
updatedAt: '2026-10-18T00:00:00.000Z',
|
||||||
|
completedAt: null,
|
||||||
|
archivedAt: null,
|
||||||
|
...overrides,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function withFilter(overrides: Partial<TaskFilter>): TaskFilter {
|
||||||
|
return { ...EMPTY_FILTER, ...overrides }
|
||||||
|
}
|
||||||
|
|
||||||
|
const today = dayjs().format('YYYY-MM-DD')
|
||||||
|
const yesterday = dayjs().subtract(1, 'day').format('YYYY-MM-DD')
|
||||||
|
const tomorrow = dayjs().add(1, 'day').format('YYYY-MM-DD')
|
||||||
|
|
||||||
|
describe('isFilterActive', () => {
|
||||||
|
it('空筛选不算激活', () => {
|
||||||
|
expect(isFilterActive(EMPTY_FILTER)).toBe(false)
|
||||||
|
expect(isFilterActive(withFilter({ keyword: ' ' }))).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('任一维度有值即算激活', () => {
|
||||||
|
expect(isFilterActive(withFilter({ keyword: 'a' }))).toBe(true)
|
||||||
|
expect(isFilterActive(withFilter({ priorities: [3] }))).toBe(true)
|
||||||
|
expect(isFilterActive(withFilter({ tags: ['设计'] }))).toBe(true)
|
||||||
|
expect(isFilterActive(withFilter({ due: 'overdue' }))).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('关键词匹配', () => {
|
||||||
|
it('匹配标题', () => {
|
||||||
|
expect(matchesFilter(makeTask({ title: '写方案' }), withFilter({ keyword: '方案' }))).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('匹配描述', () => {
|
||||||
|
expect(
|
||||||
|
matchesFilter(makeTask({ description: '需要先调研' }), withFilter({ keyword: '调研' })),
|
||||||
|
).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('大小写不敏感', () => {
|
||||||
|
expect(matchesFilter(makeTask({ title: 'Fix Bug' }), withFilter({ keyword: 'fix' }))).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('不匹配标签(标签有独立维度)', () => {
|
||||||
|
expect(
|
||||||
|
matchesFilter(makeTask({ tags: ['设计'] }), withFilter({ keyword: '设计' })),
|
||||||
|
).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('不匹配时返回 false', () => {
|
||||||
|
expect(matchesFilter(makeTask({ title: '写方案' }), withFilter({ keyword: '做饭' }))).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('优先级与标签匹配', () => {
|
||||||
|
it('优先级为空时全通过', () => {
|
||||||
|
expect(matchesFilter(makeTask({ priority: 1 }), EMPTY_FILTER)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('优先级命中其一即通过', () => {
|
||||||
|
const filter = withFilter({ priorities: [3, 1] })
|
||||||
|
expect(matchesFilter(makeTask({ priority: 1 }), filter)).toBe(true)
|
||||||
|
expect(matchesFilter(makeTask({ priority: 3 }), filter)).toBe(true)
|
||||||
|
expect(matchesFilter(makeTask({ priority: 2 }), filter)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('标签命中其一即通过', () => {
|
||||||
|
const filter = withFilter({ tags: ['设计', '前端'] })
|
||||||
|
expect(matchesFilter(makeTask({ tags: ['前端'] }), filter)).toBe(true)
|
||||||
|
expect(matchesFilter(makeTask({ tags: ['后端'] }), filter)).toBe(false)
|
||||||
|
expect(matchesFilter(makeTask({ tags: [] }), filter)).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('截止日期筛选', () => {
|
||||||
|
it('今日到期只包含未完成且截止今天的任务', () => {
|
||||||
|
const filter = withFilter({ due: 'today' })
|
||||||
|
expect(matchesFilter(makeTask({ dueDate: today }), filter)).toBe(true)
|
||||||
|
expect(matchesFilter(makeTask({ dueDate: today, status: 'done' }), filter)).toBe(false)
|
||||||
|
expect(matchesFilter(makeTask({ dueDate: tomorrow }), filter)).toBe(false)
|
||||||
|
expect(matchesFilter(makeTask({ dueDate: null }), filter)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('逾期只包含未完成且已过期的任务', () => {
|
||||||
|
const filter = withFilter({ due: 'overdue' })
|
||||||
|
expect(matchesFilter(makeTask({ dueDate: yesterday }), filter)).toBe(true)
|
||||||
|
expect(matchesFilter(makeTask({ dueDate: yesterday, status: 'done' }), filter)).toBe(false)
|
||||||
|
expect(matchesFilter(makeTask({ dueDate: today }), filter)).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('多条件组合', () => {
|
||||||
|
it('条件之间是「与」关系', () => {
|
||||||
|
const filter = withFilter({ keyword: '方案', priorities: [3] })
|
||||||
|
expect(matchesFilter(makeTask({ title: '写方案', priority: 3 }), filter)).toBe(true)
|
||||||
|
expect(matchesFilter(makeTask({ title: '写方案', priority: 2 }), filter)).toBe(false)
|
||||||
|
expect(matchesFilter(makeTask({ title: '做原型', priority: 3 }), filter)).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('filterTasks', () => {
|
||||||
|
const tasks = [
|
||||||
|
makeTask({ id: 'a', title: '写方案', priority: 3, tags: ['设计'] }),
|
||||||
|
makeTask({ id: 'b', title: '做原型', priority: 2, tags: ['设计', '前端'] }),
|
||||||
|
makeTask({ id: 'c', title: '买咖啡', priority: 1, tags: [] }),
|
||||||
|
]
|
||||||
|
|
||||||
|
it('无筛选时返回全部(且是新数组)', () => {
|
||||||
|
const result = filterTasks(tasks, EMPTY_FILTER)
|
||||||
|
expect(result).toHaveLength(3)
|
||||||
|
expect(result).not.toBe(tasks)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('按关键词过滤', () => {
|
||||||
|
expect(filterTasks(tasks, withFilter({ keyword: '原型' })).map((task) => task.id)).toEqual(['b'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('按标签过滤', () => {
|
||||||
|
expect(filterTasks(tasks, withFilter({ tags: ['设计'] })).map((task) => task.id)).toEqual([
|
||||||
|
'a',
|
||||||
|
'b',
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('无结果时返回空数组', () => {
|
||||||
|
expect(filterTasks(tasks, withFilter({ keyword: '不存在的词' }))).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('不修改入参', () => {
|
||||||
|
filterTasks(tasks, withFilter({ keyword: '方案' }))
|
||||||
|
expect(tasks).toHaveLength(3)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('归档可见性', () => {
|
||||||
|
const visible = makeTask({ id: 'visible' })
|
||||||
|
const archived = makeTask({ id: 'archived', archivedAt: '2026-10-01T00:00:00.000Z' })
|
||||||
|
|
||||||
|
it('默认隐藏归档任务', () => {
|
||||||
|
expect(filterTasks([visible, archived], EMPTY_FILTER).map((task) => task.id)).toEqual([
|
||||||
|
'visible',
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('开启 includeArchived 后显示全部', () => {
|
||||||
|
expect(
|
||||||
|
filterTasks([visible, archived], withFilter({ includeArchived: true })).map((task) => task.id),
|
||||||
|
).toEqual(['visible', 'archived'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('归档可见性不影响 isFilterActive(它是视图开关,不是筛选条件)', () => {
|
||||||
|
expect(isFilterActive(withFilter({ includeArchived: true }))).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('归档任务同样受关键词筛选', () => {
|
||||||
|
const archivedNamed = makeTask({
|
||||||
|
id: 'archived-named',
|
||||||
|
title: '归档的方案',
|
||||||
|
archivedAt: '2026-10-01T00:00:00.000Z',
|
||||||
|
})
|
||||||
|
// 默认标题是「写方案」,会命中关键词,这里显式换成不命中的标题
|
||||||
|
const plain = makeTask({ id: 'plain', title: '普通任务' })
|
||||||
|
|
||||||
|
const result = filterTasks(
|
||||||
|
[plain, archivedNamed],
|
||||||
|
withFilter({ includeArchived: true, keyword: '方案' }),
|
||||||
|
)
|
||||||
|
expect(result.map((task) => task.id)).toEqual(['archived-named'])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
/**
|
||||||
|
* 筛选规则(T15 / T16)
|
||||||
|
*
|
||||||
|
* 全部为纯函数,便于独立测试。
|
||||||
|
* 关键词只匹配标题与描述(标签有独立的筛选维度),
|
||||||
|
* 中文直接用 includes,不引入分词库。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Priority, Task } from '@/types/task'
|
||||||
|
import { isDueToday, isOverdue } from '@/utils/date'
|
||||||
|
|
||||||
|
/** 截止日期维度:今天到期 / 已逾期 */
|
||||||
|
export type DueFilter = 'today' | 'overdue'
|
||||||
|
|
||||||
|
export interface TaskFilter {
|
||||||
|
keyword: string
|
||||||
|
priorities: readonly Priority[]
|
||||||
|
tags: readonly string[]
|
||||||
|
due: DueFilter | null
|
||||||
|
/** 是否把已归档任务也纳入看板 */
|
||||||
|
includeArchived: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EMPTY_FILTER: TaskFilter = {
|
||||||
|
keyword: '',
|
||||||
|
priorities: [],
|
||||||
|
tags: [],
|
||||||
|
due: null,
|
||||||
|
includeArchived: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchesKeyword(task: Task, keyword: string): boolean {
|
||||||
|
const needle = keyword.trim().toLowerCase()
|
||||||
|
if (needle === '') return true
|
||||||
|
return (
|
||||||
|
task.title.toLowerCase().includes(needle) ||
|
||||||
|
task.description.toLowerCase().includes(needle)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchesPriority(task: Task, priorities: readonly Priority[]): boolean {
|
||||||
|
if (priorities.length === 0) return true
|
||||||
|
return priorities.includes(task.priority)
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchesTags(task: Task, tags: readonly string[]): boolean {
|
||||||
|
if (tags.length === 0) return true
|
||||||
|
return tags.some((tag) => task.tags.includes(tag))
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchesDue(task: Task, due: DueFilter | null): boolean {
|
||||||
|
if (due === null) return true
|
||||||
|
if (due === 'overdue') return isOverdue(task.dueDate, task.status)
|
||||||
|
return isDueToday(task.dueDate, task.status)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 多个条件之间是「与」关系;归档可见性优先判断 */
|
||||||
|
export function matchesFilter(task: Task, filter: TaskFilter): boolean {
|
||||||
|
if (!filter.includeArchived && task.archivedAt !== null) return false
|
||||||
|
|
||||||
|
return (
|
||||||
|
matchesKeyword(task, filter.keyword) &&
|
||||||
|
matchesPriority(task, filter.priorities) &&
|
||||||
|
matchesTags(task, filter.tags) &&
|
||||||
|
matchesDue(task, filter.due)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 是否处于「筛选态」。
|
||||||
|
* 刻意不把 includeArchived 计入:它只是视图开关,
|
||||||
|
* 而这里的结果用于判断「列内渲染的是否为子集」,进而决定要不要禁用列内排序。
|
||||||
|
* 该判断改由调用方按「隐藏数量 > 0」精确计算,见 BoardView。
|
||||||
|
*/
|
||||||
|
export function isFilterActive(filter: TaskFilter): boolean {
|
||||||
|
return (
|
||||||
|
filter.keyword.trim() !== '' ||
|
||||||
|
filter.priorities.length > 0 ||
|
||||||
|
filter.tags.length > 0 ||
|
||||||
|
filter.due !== null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function filterTasks(tasks: readonly Task[], filter: TaskFilter): Task[] {
|
||||||
|
return tasks.filter((task) => matchesFilter(task, filter))
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { afterEach, describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
import { createId } from './id'
|
||||||
|
|
||||||
|
const originalRandomUUID = Object.getOwnPropertyDescriptor(crypto, 'randomUUID')
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
if (originalRandomUUID) Object.defineProperty(crypto, 'randomUUID', originalRandomUUID)
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('createId', () => {
|
||||||
|
it('优先使用 crypto.randomUUID', () => {
|
||||||
|
Object.defineProperty(crypto, 'randomUUID', {
|
||||||
|
value: () => 'fixed-uuid',
|
||||||
|
configurable: true,
|
||||||
|
writable: true,
|
||||||
|
})
|
||||||
|
expect(createId()).toBe('fixed-uuid')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('缺少 crypto.randomUUID 时退回随机 id', () => {
|
||||||
|
Object.defineProperty(crypto, 'randomUUID', {
|
||||||
|
value: undefined,
|
||||||
|
configurable: true,
|
||||||
|
writable: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
const id = createId()
|
||||||
|
expect(id).toMatch(/^id-[a-z0-9]+-[a-z0-9]+$/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('回退实现也能保证唯一性', () => {
|
||||||
|
Object.defineProperty(crypto, 'randomUUID', {
|
||||||
|
value: undefined,
|
||||||
|
configurable: true,
|
||||||
|
writable: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
const ids = new Set(Array.from({ length: 200 }, () => createId()))
|
||||||
|
expect(ids.size).toBe(200)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
/** 生成唯一 id:优先使用浏览器内置的 crypto.randomUUID */
|
||||||
|
export function createId(): string {
|
||||||
|
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||||||
|
return crypto.randomUUID()
|
||||||
|
}
|
||||||
|
return `id-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`
|
||||||
|
}
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
import type { Task } from '@/types/task'
|
||||||
|
|
||||||
|
import {
|
||||||
|
MIN_SORT_ORDER_GAP,
|
||||||
|
SORT_ORDER_STEP,
|
||||||
|
computeSortOrder,
|
||||||
|
groupByColumn,
|
||||||
|
moveWithinColumn,
|
||||||
|
needsNormalize,
|
||||||
|
normalizeColumn,
|
||||||
|
sortByOrder,
|
||||||
|
sortOrderForPosition,
|
||||||
|
} from './sortOrder'
|
||||||
|
|
||||||
|
let sequence = 0
|
||||||
|
|
||||||
|
function makeTask(overrides: Partial<Task> = {}): Task {
|
||||||
|
sequence += 1
|
||||||
|
return {
|
||||||
|
id: `task-${sequence}`,
|
||||||
|
title: `任务 ${sequence}`,
|
||||||
|
description: '',
|
||||||
|
status: 'todo',
|
||||||
|
priority: 2,
|
||||||
|
tags: [],
|
||||||
|
dueDate: null,
|
||||||
|
sortOrder: sequence * SORT_ORDER_STEP,
|
||||||
|
createdAt: '2026-10-18T00:00:00.000Z',
|
||||||
|
updatedAt: '2026-10-18T00:00:00.000Z',
|
||||||
|
completedAt: null,
|
||||||
|
archivedAt: null,
|
||||||
|
...overrides,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 构造一列任务,sortOrder 依次为 1024, 2048, ... */
|
||||||
|
function makeColumn(count: number): Task[] {
|
||||||
|
return Array.from({ length: count }, (_, index) =>
|
||||||
|
makeTask({ sortOrder: (index + 1) * SORT_ORDER_STEP }),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function titles(tasks: readonly Task[]): string[] {
|
||||||
|
return tasks.map((task) => task.title)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('computeSortOrder', () => {
|
||||||
|
it('空列为 STEP', () => {
|
||||||
|
expect(computeSortOrder(null, null)).toBe(SORT_ORDER_STEP)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('插入列首取 next - STEP(线性递减,避免反复折半)', () => {
|
||||||
|
expect(computeSortOrder(null, 1024)).toBe(0)
|
||||||
|
expect(computeSortOrder(null, 2048)).toBe(1024)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('插入列尾取 prev + STEP', () => {
|
||||||
|
expect(computeSortOrder(1024, null)).toBe(2048)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('插入中间取中点', () => {
|
||||||
|
expect(computeSortOrder(1024, 2048)).toBe(1536)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('sortOrderForPosition', () => {
|
||||||
|
it('空列任意位置都得到 STEP', () => {
|
||||||
|
expect(sortOrderForPosition([], 0)).toBe(SORT_ORDER_STEP)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('列首小于原首个元素', () => {
|
||||||
|
const column = makeColumn(2)
|
||||||
|
expect(sortOrderForPosition(column, 0)).toBeLessThan(column[0]?.sortOrder ?? 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('中间位置严格介于前后之间', () => {
|
||||||
|
const column = makeColumn(2)
|
||||||
|
const order = sortOrderForPosition(column, 1)
|
||||||
|
expect(order).toBeGreaterThan(column[0]?.sortOrder ?? 0)
|
||||||
|
expect(order).toBeLessThan(column[1]?.sortOrder ?? 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('末尾位置大于原末个元素', () => {
|
||||||
|
const column = makeColumn(2)
|
||||||
|
expect(sortOrderForPosition(column, 2)).toBeGreaterThan(column[1]?.sortOrder ?? 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('越界下标被裁剪到合法范围', () => {
|
||||||
|
const column = makeColumn(2)
|
||||||
|
expect(sortOrderForPosition(column, -5)).toBe(sortOrderForPosition(column, 0))
|
||||||
|
expect(sortOrderForPosition(column, 99)).toBe(sortOrderForPosition(column, 2))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('moveWithinColumn', () => {
|
||||||
|
it('把首张卡片移到索引 2', () => {
|
||||||
|
const column = makeColumn(4)
|
||||||
|
const moved = moveWithinColumn(column, 0, 2)
|
||||||
|
expect(titles(moved)).toEqual([
|
||||||
|
column[1]?.title,
|
||||||
|
column[2]?.title,
|
||||||
|
column[0]?.title,
|
||||||
|
column[3]?.title,
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('把末张卡片移到索引 0', () => {
|
||||||
|
const column = makeColumn(4)
|
||||||
|
const moved = moveWithinColumn(column, 3, 0)
|
||||||
|
expect(titles(moved)).toEqual([
|
||||||
|
column[3]?.title,
|
||||||
|
column[0]?.title,
|
||||||
|
column[1]?.title,
|
||||||
|
column[2]?.title,
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('移动后数组仍按 sortOrder 升序', () => {
|
||||||
|
const moved = moveWithinColumn(makeColumn(5), 1, 3)
|
||||||
|
const orders = moved.map((task) => task.sortOrder)
|
||||||
|
expect([...orders].sort((a, b) => a - b)).toEqual(orders)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('不修改入参数组与其中对象', () => {
|
||||||
|
const column = makeColumn(3)
|
||||||
|
const snapshot = column.map((task) => task.sortOrder)
|
||||||
|
moveWithinColumn(column, 0, 2)
|
||||||
|
expect(column.map((task) => task.sortOrder)).toEqual(snapshot)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('非法 fromIndex 返回原数组副本', () => {
|
||||||
|
const column = makeColumn(2)
|
||||||
|
expect(titles(moveWithinColumn(column, 9, 0))).toEqual(titles(column))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('精度保护', () => {
|
||||||
|
it('needsNormalize 能识别过小间距', () => {
|
||||||
|
const tight = [
|
||||||
|
makeTask({ sortOrder: 1024 }),
|
||||||
|
makeTask({ sortOrder: 1024 + MIN_SORT_ORDER_GAP / 2 }),
|
||||||
|
]
|
||||||
|
expect(needsNormalize(tight)).toBe(true)
|
||||||
|
expect(needsNormalize(makeColumn(5))).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('连续 100 次「插到锚点之后」后间距仍然充足', () => {
|
||||||
|
let column = makeColumn(1)
|
||||||
|
let normalizations = 0
|
||||||
|
|
||||||
|
for (let round = 0; round < 100; round += 1) {
|
||||||
|
const order = sortOrderForPosition(column, 1)
|
||||||
|
const inserted = makeTask({ sortOrder: order })
|
||||||
|
column = [...column.slice(0, 1), inserted, ...column.slice(1)]
|
||||||
|
if (needsNormalize(column)) {
|
||||||
|
column = normalizeColumn(column)
|
||||||
|
normalizations += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(column).toHaveLength(101)
|
||||||
|
expect(needsNormalize(column)).toBe(false)
|
||||||
|
|
||||||
|
for (let index = 1; index < column.length; index += 1) {
|
||||||
|
const previous = column[index - 1]?.sortOrder ?? 0
|
||||||
|
const current = column[index]?.sortOrder ?? 0
|
||||||
|
expect(current - previous).toBeGreaterThanOrEqual(MIN_SORT_ORDER_GAP)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 每约 30 次插入才需要一次 normalize,说明中点插入是有效的
|
||||||
|
expect(normalizations).toBeGreaterThan(0)
|
||||||
|
expect(normalizations).toBeLessThan(10)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('normalizeColumn 重新均匀分配且保持相对顺序', () => {
|
||||||
|
const column = [
|
||||||
|
makeTask({ title: 'A', sortOrder: 1 }),
|
||||||
|
makeTask({ title: 'B', sortOrder: 1.0000001 }),
|
||||||
|
makeTask({ title: 'C', sortOrder: 99 }),
|
||||||
|
]
|
||||||
|
const normalized = normalizeColumn(column)
|
||||||
|
expect(normalized.map((task) => task.sortOrder)).toEqual([
|
||||||
|
SORT_ORDER_STEP,
|
||||||
|
SORT_ORDER_STEP * 2,
|
||||||
|
SORT_ORDER_STEP * 3,
|
||||||
|
])
|
||||||
|
expect(titles(normalized)).toEqual(['A', 'B', 'C'])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('groupByColumn', () => {
|
||||||
|
it('按列分组且每列按 sortOrder 升序', () => {
|
||||||
|
const tasks = [
|
||||||
|
makeTask({ title: '待办后', status: 'todo', sortOrder: 2048 }),
|
||||||
|
makeTask({ title: '进行中', status: 'doing', sortOrder: 1024 }),
|
||||||
|
makeTask({ title: '待办前', status: 'todo', sortOrder: 1024 }),
|
||||||
|
]
|
||||||
|
const grouped = groupByColumn(tasks)
|
||||||
|
expect(grouped.todo.map((task) => task.title)).toEqual(['待办前', '待办后'])
|
||||||
|
expect(grouped.doing.map((task) => task.title)).toEqual(['进行中'])
|
||||||
|
expect(grouped.done).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('空列也会出现在结果中', () => {
|
||||||
|
const grouped = groupByColumn([])
|
||||||
|
expect(Object.keys(grouped).sort()).toEqual(['doing', 'done', 'todo'])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('sortByOrder', () => {
|
||||||
|
it('不修改入参', () => {
|
||||||
|
const tasks = [
|
||||||
|
makeTask({ title: 'B', sortOrder: 2048 }),
|
||||||
|
makeTask({ title: 'A', sortOrder: 1024 }),
|
||||||
|
]
|
||||||
|
expect(titles(sortByOrder(tasks))).toEqual(['A', 'B'])
|
||||||
|
expect(titles(tasks)).toEqual(['B', 'A'])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
/**
|
||||||
|
* 同列排序算法(T05)
|
||||||
|
*
|
||||||
|
* 采用「相邻中点插入」:在两张卡片之间插入时取两者 sortOrder 的中间值,
|
||||||
|
* 避免每次插入都重写整列。当中点间距小于 MIN_SORT_ORDER_GAP 时,
|
||||||
|
* 对该列做一次 normalize(按 SORT_ORDER_STEP 重新均匀分配)。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { COLUMN_IDS, type ColumnId, type Task } from '@/types/task'
|
||||||
|
|
||||||
|
/** normalize 时同列相邻任务的排序间隔 */
|
||||||
|
export const SORT_ORDER_STEP = 1024
|
||||||
|
|
||||||
|
/** 小于该间距即认为浮点精度即将耗尽,需要 normalize */
|
||||||
|
export const MIN_SORT_ORDER_GAP = 1e-6
|
||||||
|
|
||||||
|
/** 计算插入到 previous 与 next 之间应使用的 sortOrder */
|
||||||
|
export function computeSortOrder(previous: number | null, next: number | null): number {
|
||||||
|
if (previous === null) {
|
||||||
|
return next === null ? SORT_ORDER_STEP : next - SORT_ORDER_STEP
|
||||||
|
}
|
||||||
|
if (next === null) return previous + SORT_ORDER_STEP
|
||||||
|
return (previous + next) / 2
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 依据已排序的同列任务,计算插入到 index 位置应使用的 sortOrder。
|
||||||
|
* index 取值范围 [0, sorted.length],等于 length 表示追加到末尾。
|
||||||
|
*/
|
||||||
|
export function sortOrderForPosition(sorted: readonly Task[], index: number): number {
|
||||||
|
const clamped = Math.max(0, Math.min(index, sorted.length))
|
||||||
|
const previous = clamped > 0 ? (sorted[clamped - 1]?.sortOrder ?? null) : null
|
||||||
|
const next = clamped < sorted.length ? (sorted[clamped]?.sortOrder ?? null) : null
|
||||||
|
return computeSortOrder(previous, next)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function compareBySortOrder(a: Task, b: Task): number {
|
||||||
|
return a.sortOrder - b.sortOrder
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sortByOrder(tasks: readonly Task[]): Task[] {
|
||||||
|
return [...tasks].sort(compareBySortOrder)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 按 SORT_ORDER_STEP 重新均匀分配同列 sortOrder,保持相对顺序不变 */
|
||||||
|
export function normalizeColumn(sorted: readonly Task[]): Task[] {
|
||||||
|
return sorted.map((task, index) => ({ ...task, sortOrder: (index + 1) * SORT_ORDER_STEP }))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 判断该列是否已出现精度不足的相邻间距 */
|
||||||
|
export function needsNormalize(sorted: readonly Task[]): boolean {
|
||||||
|
for (let index = 1; index < sorted.length; index += 1) {
|
||||||
|
const previous = sorted[index - 1]
|
||||||
|
const current = sorted[index]
|
||||||
|
if (!previous || !current) continue
|
||||||
|
if (current.sortOrder - previous.sortOrder < MIN_SORT_ORDER_GAP) return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 列内移动:把 fromIndex 处的任务移动到 toIndex。
|
||||||
|
* toIndex 为「移动完成后该任务所在的最终下标」(与拖拽库给出的 newIndex 语义一致)。
|
||||||
|
* 返回新数组,不修改入参。
|
||||||
|
*/
|
||||||
|
export function moveWithinColumn(
|
||||||
|
sorted: readonly Task[],
|
||||||
|
fromIndex: number,
|
||||||
|
toIndex: number,
|
||||||
|
): Task[] {
|
||||||
|
const moving = sorted[fromIndex]
|
||||||
|
if (!moving) return [...sorted]
|
||||||
|
|
||||||
|
const rest = sorted.filter((_, index) => index !== fromIndex)
|
||||||
|
const target = Math.max(0, Math.min(toIndex, rest.length))
|
||||||
|
const moved: Task = { ...moving, sortOrder: sortOrderForPosition(rest, target) }
|
||||||
|
const result = [...rest.slice(0, target), moved, ...rest.slice(target)]
|
||||||
|
|
||||||
|
return needsNormalize(result) ? normalizeColumn(result) : result
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 按列分组并按 sortOrder 升序排列;空列也会出现,值为空数组 */
|
||||||
|
export function groupByColumn(tasks: readonly Task[]): Record<ColumnId, Task[]> {
|
||||||
|
const grouped = {} as Record<ColumnId, Task[]>
|
||||||
|
for (const columnId of COLUMN_IDS) grouped[columnId] = []
|
||||||
|
for (const task of tasks) grouped[task.status].push(task)
|
||||||
|
for (const columnId of COLUMN_IDS) grouped[columnId].sort(compareBySortOrder)
|
||||||
|
return grouped
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
import type { Task } from '@/types/task'
|
||||||
|
|
||||||
|
import { computeStats, startOfWeekIso } from './stats'
|
||||||
|
|
||||||
|
/** 2026-10-18 是周日,因此本周(周一为始)应从 2026-10-12 开始 */
|
||||||
|
const NOW = new Date(2026, 9, 18, 12, 0, 0)
|
||||||
|
const TODAY = '2026-10-18'
|
||||||
|
const WEEK_START = '2026-10-12'
|
||||||
|
|
||||||
|
function localIso(year: number, month: number, day: number, hour = 10): string {
|
||||||
|
return new Date(year, month - 1, day, hour, 0, 0).toISOString()
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeTask(overrides: Partial<Task> = {}): Task {
|
||||||
|
return {
|
||||||
|
id: 'task-1',
|
||||||
|
title: '任务',
|
||||||
|
description: '',
|
||||||
|
status: 'todo',
|
||||||
|
priority: 2,
|
||||||
|
tags: [],
|
||||||
|
dueDate: null,
|
||||||
|
sortOrder: 1024,
|
||||||
|
createdAt: '2026-10-01T00:00:00.000Z',
|
||||||
|
updatedAt: '2026-10-01T00:00:00.000Z',
|
||||||
|
completedAt: null,
|
||||||
|
archivedAt: null,
|
||||||
|
...overrides,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('startOfWeekIso', () => {
|
||||||
|
it('以周一为一周之始', () => {
|
||||||
|
expect(startOfWeekIso(NOW)).toBe(WEEK_START)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('周日属于「刚过去的那一周」而不是下一周', () => {
|
||||||
|
const sunday = new Date(2026, 9, 18, 23, 0, 0)
|
||||||
|
expect(sunday.getDay()).toBe(0)
|
||||||
|
expect(startOfWeekIso(sunday)).toBe(WEEK_START)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('周一当天返回自己', () => {
|
||||||
|
const monday = new Date(2026, 9, 12, 8, 0, 0)
|
||||||
|
expect(monday.getDay()).toBe(1)
|
||||||
|
expect(startOfWeekIso(monday)).toBe(WEEK_START)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('跨月也能正确回退', () => {
|
||||||
|
// 2026-11-01 是周日,本周起始应为 2026-10-26
|
||||||
|
const sunday = new Date(2026, 10, 1, 12, 0, 0)
|
||||||
|
expect(sunday.getDay()).toBe(0)
|
||||||
|
expect(startOfWeekIso(sunday)).toBe('2026-10-26')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('computeStats', () => {
|
||||||
|
it('统计各列数量与未完成数', () => {
|
||||||
|
const stats = computeStats(
|
||||||
|
[
|
||||||
|
makeTask({ id: 'a', status: 'todo' }),
|
||||||
|
makeTask({ id: 'b', status: 'doing' }),
|
||||||
|
makeTask({ id: 'c', status: 'done', completedAt: localIso(2026, 10, 18) }),
|
||||||
|
],
|
||||||
|
NOW,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(stats.total).toBe(3)
|
||||||
|
expect(stats.byColumn).toEqual({ todo: 1, doing: 1, done: 1 })
|
||||||
|
expect(stats.active).toBe(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('统计逾期与今天到期', () => {
|
||||||
|
const stats = computeStats(
|
||||||
|
[
|
||||||
|
makeTask({ id: 'a', dueDate: '2026-10-01' }),
|
||||||
|
makeTask({ id: 'b', dueDate: '2026-10-17' }),
|
||||||
|
makeTask({ id: 'c', dueDate: TODAY }),
|
||||||
|
makeTask({ id: 'd', dueDate: '2026-12-01' }),
|
||||||
|
],
|
||||||
|
NOW,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(stats.overdue).toBe(2)
|
||||||
|
expect(stats.dueToday).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('已完成任务不计入逾期与今天到期', () => {
|
||||||
|
const stats = computeStats(
|
||||||
|
[
|
||||||
|
makeTask({ id: 'a', status: 'done', dueDate: '2026-10-01', completedAt: localIso(2026, 10, 18) }),
|
||||||
|
makeTask({ id: 'b', status: 'done', dueDate: TODAY, completedAt: localIso(2026, 10, 18) }),
|
||||||
|
],
|
||||||
|
NOW,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(stats.overdue).toBe(0)
|
||||||
|
expect(stats.dueToday).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('本周完成数只统计本周一之后完成的', () => {
|
||||||
|
const stats = computeStats(
|
||||||
|
[
|
||||||
|
makeTask({ id: 'this-week', status: 'done', completedAt: localIso(2026, 10, 12) }),
|
||||||
|
makeTask({ id: 'sunday', status: 'done', completedAt: localIso(2026, 10, 18) }),
|
||||||
|
makeTask({ id: 'last-week', status: 'done', completedAt: localIso(2026, 10, 11) }),
|
||||||
|
makeTask({ id: 'long-ago', status: 'done', completedAt: localIso(2026, 9, 1) }),
|
||||||
|
],
|
||||||
|
NOW,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(stats.completedThisWeek).toBe(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('未完成的任务不计入本周完成数', () => {
|
||||||
|
const stats = computeStats([makeTask({ id: 'a', status: 'todo', completedAt: null })], NOW)
|
||||||
|
expect(stats.completedThisWeek).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('归档任务仍计入完成数与归档数', () => {
|
||||||
|
const stats = computeStats(
|
||||||
|
[
|
||||||
|
makeTask({
|
||||||
|
id: 'archived',
|
||||||
|
status: 'done',
|
||||||
|
completedAt: localIso(2026, 10, 15),
|
||||||
|
archivedAt: localIso(2026, 10, 16),
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
NOW,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(stats.archived).toBe(1)
|
||||||
|
expect(stats.completedThisWeek).toBe(1)
|
||||||
|
expect(stats.total).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('空列表返回全零', () => {
|
||||||
|
const stats = computeStats([], NOW)
|
||||||
|
expect(stats.total).toBe(0)
|
||||||
|
expect(stats.active).toBe(0)
|
||||||
|
expect(stats.overdue).toBe(0)
|
||||||
|
expect(stats.dueToday).toBe(0)
|
||||||
|
expect(stats.completedThisWeek).toBe(0)
|
||||||
|
expect(stats.archived).toBe(0)
|
||||||
|
expect(stats.byColumn).toEqual({ todo: 0, doing: 0, done: 0 })
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
/**
|
||||||
|
* 简单统计(今日视图顶部卡片)
|
||||||
|
*
|
||||||
|
* 全部为纯函数,「当前时间」可注入,便于测试跨周边界的用例。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import dayjs from 'dayjs'
|
||||||
|
|
||||||
|
import { COLUMN_IDS, type ColumnId, type Task } from '@/types/task'
|
||||||
|
import { isDueToday, isOverdue } from '@/utils/date'
|
||||||
|
|
||||||
|
export interface TaskStats {
|
||||||
|
total: number
|
||||||
|
byColumn: Record<ColumnId, number>
|
||||||
|
/** 未完成的任务数 */
|
||||||
|
active: number
|
||||||
|
overdue: number
|
||||||
|
dueToday: number
|
||||||
|
/** 本周(周一为一周之始)完成的任务数 */
|
||||||
|
completedThisWeek: number
|
||||||
|
archived: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 本周起始日(周一)的日期字符串。
|
||||||
|
* dayjs 的 day() 是 0=周日 … 6=周六,而中文语境习惯以周一为一周之始,
|
||||||
|
* 因此偏移量为 (day + 6) % 7。
|
||||||
|
*/
|
||||||
|
export function startOfWeekIso(now: Date = new Date()): string {
|
||||||
|
const current = dayjs(now).startOf('day')
|
||||||
|
const offset = (current.day() + 6) % 7
|
||||||
|
return current.subtract(offset, 'day').format('YYYY-MM-DD')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 把时间戳转成本地日期字符串(completedAt 存的是 UTC ISO,直接截取会差一天) */
|
||||||
|
function localDateOf(iso: string): string {
|
||||||
|
return dayjs(iso).format('YYYY-MM-DD')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computeStats(tasks: readonly Task[], now: Date = new Date()): TaskStats {
|
||||||
|
const byColumn = {} as Record<ColumnId, number>
|
||||||
|
for (const columnId of COLUMN_IDS) byColumn[columnId] = 0
|
||||||
|
|
||||||
|
const today = dayjs(now).format('YYYY-MM-DD')
|
||||||
|
const weekStart = startOfWeekIso(now)
|
||||||
|
|
||||||
|
let overdue = 0
|
||||||
|
let dueToday = 0
|
||||||
|
let completedThisWeek = 0
|
||||||
|
let archived = 0
|
||||||
|
|
||||||
|
for (const task of tasks) {
|
||||||
|
byColumn[task.status] += 1
|
||||||
|
|
||||||
|
if (task.archivedAt !== null) archived += 1
|
||||||
|
if (isOverdue(task.dueDate, task.status, today)) overdue += 1
|
||||||
|
if (isDueToday(task.dueDate, task.status, today)) dueToday += 1
|
||||||
|
if (task.completedAt !== null && localDateOf(task.completedAt) >= weekStart) {
|
||||||
|
completedThisWeek += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
total: tasks.length,
|
||||||
|
byColumn,
|
||||||
|
active: tasks.length - byColumn.done,
|
||||||
|
overdue,
|
||||||
|
dueToday,
|
||||||
|
completedThisWeek,
|
||||||
|
archived,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
import type { Task } from '@/types/task'
|
||||||
|
|
||||||
|
import { buildTodayBuckets, countTodayTasks, sortByUrgency } from './today'
|
||||||
|
|
||||||
|
const TODAY = '2026-10-18'
|
||||||
|
|
||||||
|
function makeTask(overrides: Partial<Task> = {}): Task {
|
||||||
|
return {
|
||||||
|
id: 'task-1',
|
||||||
|
title: '任务',
|
||||||
|
description: '',
|
||||||
|
status: 'todo',
|
||||||
|
priority: 2,
|
||||||
|
tags: [],
|
||||||
|
dueDate: null,
|
||||||
|
sortOrder: 1024,
|
||||||
|
createdAt: '2026-10-01T00:00:00.000Z',
|
||||||
|
updatedAt: '2026-10-01T00:00:00.000Z',
|
||||||
|
completedAt: null,
|
||||||
|
archivedAt: null,
|
||||||
|
...overrides,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ids(tasks: readonly Task[]): string[] {
|
||||||
|
return tasks.map((task) => task.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('buildTodayBuckets 分组', () => {
|
||||||
|
it('逾期任务进入 overdue', () => {
|
||||||
|
const task = makeTask({ id: 'a', dueDate: '2026-10-17' })
|
||||||
|
expect(ids(buildTodayBuckets([task], TODAY).overdue)).toEqual(['a'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('今天到期的进入 dueToday', () => {
|
||||||
|
const task = makeTask({ id: 'a', dueDate: TODAY })
|
||||||
|
expect(ids(buildTodayBuckets([task], TODAY).dueToday)).toEqual(['a'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('进行中且无更紧急截止日期的进入 inProgress', () => {
|
||||||
|
const task = makeTask({ id: 'a', status: 'doing' })
|
||||||
|
expect(ids(buildTodayBuckets([task], TODAY).inProgress)).toEqual(['a'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('未开始且没有截止日期的任务不出现', () => {
|
||||||
|
const task = makeTask({ id: 'a', status: 'todo', dueDate: null })
|
||||||
|
const buckets = buildTodayBuckets([task], TODAY)
|
||||||
|
expect(countTodayTasks(buckets)).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('未来截止但未开始的任务不出现', () => {
|
||||||
|
const task = makeTask({ id: 'a', status: 'todo', dueDate: '2026-12-01' })
|
||||||
|
expect(countTodayTasks(buildTodayBuckets([task], TODAY))).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('已完成任务不出现', () => {
|
||||||
|
const task = makeTask({ id: 'a', status: 'done', dueDate: '2026-10-01', completedAt: null })
|
||||||
|
expect(countTodayTasks(buildTodayBuckets([task], TODAY))).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('已归档任务不出现', () => {
|
||||||
|
const task = makeTask({ id: 'a', dueDate: '2026-10-01', archivedAt: '2026-10-05T00:00:00.000Z' })
|
||||||
|
expect(countTodayTasks(buildTodayBuckets([task], TODAY))).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('每个任务只归属一组:逾期优先于今天到期,今天到期优先于进行中', () => {
|
||||||
|
const overdue = makeTask({ id: 'overdue', status: 'doing', dueDate: '2026-10-01' })
|
||||||
|
const dueToday = makeTask({ id: 'dueToday', status: 'doing', dueDate: TODAY })
|
||||||
|
const doing = makeTask({ id: 'doing', status: 'doing' })
|
||||||
|
|
||||||
|
const buckets = buildTodayBuckets([overdue, dueToday, doing], TODAY)
|
||||||
|
|
||||||
|
expect(ids(buckets.overdue)).toEqual(['overdue'])
|
||||||
|
expect(ids(buckets.dueToday)).toEqual(['dueToday'])
|
||||||
|
expect(ids(buckets.inProgress)).toEqual(['doing'])
|
||||||
|
expect(countTodayTasks(buckets)).toBe(3)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('sortByUrgency', () => {
|
||||||
|
it('优先级高的排前面', () => {
|
||||||
|
const low = makeTask({ id: 'low', priority: 1 })
|
||||||
|
const high = makeTask({ id: 'high', priority: 3 })
|
||||||
|
const medium = makeTask({ id: 'medium', priority: 2 })
|
||||||
|
|
||||||
|
expect(ids(sortByUrgency([low, high, medium]))).toEqual(['high', 'medium', 'low'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('同优先级下截止日期早的排前面', () => {
|
||||||
|
const later = makeTask({ id: 'later', priority: 2, dueDate: '2026-10-20' })
|
||||||
|
const earlier = makeTask({ id: 'earlier', priority: 2, dueDate: '2026-10-10' })
|
||||||
|
|
||||||
|
expect(ids(sortByUrgency([later, earlier]))).toEqual(['earlier', 'later'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('没有截止日期的排在有截止日期的后面', () => {
|
||||||
|
const none = makeTask({ id: 'none', priority: 2, dueDate: null })
|
||||||
|
const dated = makeTask({ id: 'dated', priority: 2, dueDate: '2026-12-31' })
|
||||||
|
|
||||||
|
expect(ids(sortByUrgency([none, dated]))).toEqual(['dated', 'none'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('条件完全相同时按 sortOrder 稳定排序', () => {
|
||||||
|
const second = makeTask({ id: 'second', priority: 2, dueDate: TODAY, sortOrder: 2048 })
|
||||||
|
const first = makeTask({ id: 'first', priority: 2, dueDate: TODAY, sortOrder: 1024 })
|
||||||
|
|
||||||
|
expect(ids(sortByUrgency([second, first]))).toEqual(['first', 'second'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('不修改入参', () => {
|
||||||
|
const tasks = [makeTask({ id: 'a', priority: 1 }), makeTask({ id: 'b', priority: 3 })]
|
||||||
|
sortByUrgency(tasks)
|
||||||
|
expect(ids(tasks)).toEqual(['a', 'b'])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('buildTodayBuckets 排序', () => {
|
||||||
|
it('各组内部按紧急度排序', () => {
|
||||||
|
const low = makeTask({ id: 'low', dueDate: '2026-10-01', priority: 1 })
|
||||||
|
const high = makeTask({ id: 'high', dueDate: '2026-10-02', priority: 3 })
|
||||||
|
|
||||||
|
expect(ids(buildTodayBuckets([low, high], TODAY).overdue)).toEqual(['high', 'low'])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('countTodayTasks', () => {
|
||||||
|
it('统计三组之和', () => {
|
||||||
|
const tasks = [
|
||||||
|
makeTask({ id: 'a', dueDate: '2026-10-01' }),
|
||||||
|
makeTask({ id: 'b', dueDate: TODAY }),
|
||||||
|
makeTask({ id: 'c', status: 'doing' }),
|
||||||
|
]
|
||||||
|
expect(countTodayTasks(buildTodayBuckets(tasks, TODAY))).toBe(3)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('空列表为 0', () => {
|
||||||
|
expect(countTodayTasks(buildTodayBuckets([], TODAY))).toBe(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
/**
|
||||||
|
* 「今日」视图的分组逻辑
|
||||||
|
*
|
||||||
|
* 把「今天该做的事」分成三组,每个任务只归属一组,归属优先级为:
|
||||||
|
* **逾期 > 今天到期 > 进行中**。
|
||||||
|
*
|
||||||
|
* 之所以要保证「只归属一组」,是因为同一张卡片在页面上出现两次
|
||||||
|
* 会让人怀疑自己看错了;分组之间互斥,页面上就能一眼数清今天有几件事。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Task } from '@/types/task'
|
||||||
|
import { todayIso } from '@/utils/date'
|
||||||
|
|
||||||
|
export interface TodayBuckets {
|
||||||
|
/** 已过截止日期,最紧急 */
|
||||||
|
overdue: Task[]
|
||||||
|
/** 今天到期 */
|
||||||
|
dueToday: Task[]
|
||||||
|
/** 正在进行、且没有更紧急的截止日期 */
|
||||||
|
inProgress: Task[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 紧急度排序:优先级降序 → 截止日期升序(无截止日期排最后)→ 列内顺序。
|
||||||
|
* 最后一级保证排序稳定,避免相同条件的卡片每次渲染都换位置。
|
||||||
|
*/
|
||||||
|
export function sortByUrgency(tasks: readonly Task[]): Task[] {
|
||||||
|
return [...tasks].sort((a, b) => {
|
||||||
|
if (a.priority !== b.priority) return b.priority - a.priority
|
||||||
|
|
||||||
|
if (a.dueDate !== b.dueDate) {
|
||||||
|
if (a.dueDate === null) return 1
|
||||||
|
if (b.dueDate === null) return -1
|
||||||
|
return a.dueDate < b.dueDate ? -1 : 1
|
||||||
|
}
|
||||||
|
|
||||||
|
return a.sortOrder - b.sortOrder
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 已完成与已归档的任务不进入今日视图 */
|
||||||
|
function isActionable(task: Task): boolean {
|
||||||
|
return task.status !== 'done' && task.archivedAt === null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildTodayBuckets(tasks: readonly Task[], today: string = todayIso()): TodayBuckets {
|
||||||
|
const overdue: Task[] = []
|
||||||
|
const dueToday: Task[] = []
|
||||||
|
const inProgress: Task[] = []
|
||||||
|
|
||||||
|
for (const task of tasks) {
|
||||||
|
if (!isActionable(task)) continue
|
||||||
|
|
||||||
|
if (task.dueDate !== null && task.dueDate < today) {
|
||||||
|
overdue.push(task)
|
||||||
|
} else if (task.dueDate === today) {
|
||||||
|
dueToday.push(task)
|
||||||
|
} else if (task.status === 'doing') {
|
||||||
|
inProgress.push(task)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
overdue: sortByUrgency(overdue),
|
||||||
|
dueToday: sortByUrgency(dueToday),
|
||||||
|
inProgress: sortByUrgency(inProgress),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 今日视图中的任务总数 */
|
||||||
|
export function countTodayTasks(buckets: TodayBuckets): number {
|
||||||
|
return buckets.overdue.length + buckets.dueToday.length + buckets.inProgress.length
|
||||||
|
}
|
||||||
@@ -0,0 +1,450 @@
|
|||||||
|
import { enableAutoUnmount, mount } from '@vue/test-utils'
|
||||||
|
import dayjs from 'dayjs'
|
||||||
|
import { createPinia, setActivePinia } from 'pinia'
|
||||||
|
import { nextTick } from 'vue'
|
||||||
|
import { afterEach, describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
import BoardColumn from '@/components/BoardColumn.vue'
|
||||||
|
import { createMemoryTaskRepository } from '@/data/taskRepository'
|
||||||
|
import { useFilterStore } from '@/stores/filters'
|
||||||
|
import { useTaskStore } from '@/stores/tasks'
|
||||||
|
import { useToastStore } from '@/stores/toast'
|
||||||
|
|
||||||
|
import BoardView from './BoardView.vue'
|
||||||
|
|
||||||
|
// BoardView 会在 document 上注册快捷键监听,必须逐个卸载避免相互干扰
|
||||||
|
enableAutoUnmount(afterEach)
|
||||||
|
|
||||||
|
function setup() {
|
||||||
|
const pinia = createPinia()
|
||||||
|
setActivePinia(pinia)
|
||||||
|
const store = useTaskStore()
|
||||||
|
store.init({ repository: createMemoryTaskRepository(), crossTab: false })
|
||||||
|
return { pinia, store }
|
||||||
|
}
|
||||||
|
|
||||||
|
function mountBoard(pinia: ReturnType<typeof createPinia>) {
|
||||||
|
// 抽屉内容会被 Teleport 到 body,测试中改为就地渲染以便断言
|
||||||
|
return mount(BoardView, {
|
||||||
|
global: { plugins: [pinia], stubs: { teleport: true } },
|
||||||
|
attachTo: document.body,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('BoardView 结构', () => {
|
||||||
|
it('渲染三个固定列', () => {
|
||||||
|
const { pinia } = setup()
|
||||||
|
const headings = mountBoard(pinia)
|
||||||
|
.findAll('h2')
|
||||||
|
.map((node) => node.text())
|
||||||
|
expect(headings).toEqual(['待办', '进行中', '已完成'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('空看板显示空状态', () => {
|
||||||
|
const { pinia } = setup()
|
||||||
|
const wrapper = mountBoard(pinia)
|
||||||
|
expect(wrapper.findAll('article')).toHaveLength(0)
|
||||||
|
expect(wrapper.text()).toContain('暂无任务')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('每列显示任务计数', () => {
|
||||||
|
const { pinia, store } = setup()
|
||||||
|
store.createTask({ title: '待办事项' })
|
||||||
|
store.createTask({ title: '进行中事项', status: 'doing' })
|
||||||
|
|
||||||
|
const counters = mountBoard(pinia)
|
||||||
|
.findAll('section header span')
|
||||||
|
.map((node) => node.text())
|
||||||
|
expect(counters).toEqual(['1', '1', '0'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('渲染任务标题并显示总数', () => {
|
||||||
|
const { pinia, store } = setup()
|
||||||
|
store.createTask({ title: '写方案' })
|
||||||
|
store.createTask({ title: '做原型', status: 'doing' })
|
||||||
|
|
||||||
|
const wrapper = mountBoard(pinia)
|
||||||
|
expect(wrapper.text()).toContain('写方案')
|
||||||
|
expect(wrapper.text()).toContain('做原型')
|
||||||
|
expect(wrapper.text()).toContain('共 2 个任务')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('任务按列归位', () => {
|
||||||
|
const { pinia, store } = setup()
|
||||||
|
store.createTask({ title: '待办事项' })
|
||||||
|
store.createTask({ title: '进行中事项', status: 'doing' })
|
||||||
|
store.createTask({ title: '已完成事项', status: 'done' })
|
||||||
|
|
||||||
|
const sections = mountBoard(pinia).findAll('section')
|
||||||
|
expect(sections[0]?.text()).toContain('待办事项')
|
||||||
|
expect(sections[0]?.text()).not.toContain('进行中事项')
|
||||||
|
expect(sections[1]?.text()).toContain('进行中事项')
|
||||||
|
expect(sections[2]?.text()).toContain('已完成事项')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('存在存储提示时展示提示条', () => {
|
||||||
|
const { pinia, store } = setup()
|
||||||
|
store.notice = '本地存储不可用'
|
||||||
|
expect(mountBoard(pinia).text()).toContain('本地存储不可用')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('BoardView 端到端交互', () => {
|
||||||
|
it('在界面上输入并回车即可新增任务', async () => {
|
||||||
|
const { pinia, store } = setup()
|
||||||
|
const wrapper = mountBoard(pinia)
|
||||||
|
|
||||||
|
const firstColumnInput = wrapper.findAll('section')[0]?.find('input')
|
||||||
|
expect(firstColumnInput).toBeDefined()
|
||||||
|
|
||||||
|
await firstColumnInput?.setValue('界面新增的任务')
|
||||||
|
await wrapper.findAll('form')[0]?.trigger('submit')
|
||||||
|
|
||||||
|
expect(store.byColumn.todo.map((task) => task.title)).toEqual(['界面新增的任务'])
|
||||||
|
expect(wrapper.text()).toContain('界面新增的任务')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('点击完成按钮后卡片移动到已完成列', async () => {
|
||||||
|
const { pinia, store } = setup()
|
||||||
|
store.createTask({ title: '待办任务' })
|
||||||
|
const wrapper = mountBoard(pinia)
|
||||||
|
|
||||||
|
await wrapper.find('article button').trigger('click')
|
||||||
|
|
||||||
|
expect(store.byColumn.todo).toHaveLength(0)
|
||||||
|
expect(store.byColumn.done.map((task) => task.title)).toEqual(['待办任务'])
|
||||||
|
expect(wrapper.findAll('section')[2]?.text()).toContain('待办任务')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('再次点击可撤销完成', async () => {
|
||||||
|
const { pinia, store } = setup()
|
||||||
|
store.createTask({ title: '待办任务' })
|
||||||
|
const wrapper = mountBoard(pinia)
|
||||||
|
|
||||||
|
await wrapper.find('article button').trigger('click')
|
||||||
|
await wrapper.find('article button').trigger('click')
|
||||||
|
|
||||||
|
expect(store.byColumn.todo.map((task) => task.title)).toEqual(['待办任务'])
|
||||||
|
expect(store.byColumn.done).toHaveLength(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('BoardView 详情抽屉', () => {
|
||||||
|
it('初始不渲染详情表单', () => {
|
||||||
|
const { pinia, store } = setup()
|
||||||
|
store.createTask({ title: '待办任务' })
|
||||||
|
expect(mountBoard(pinia).find('input[aria-label="标题"]').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('点击卡片打开抽屉并回填标题', async () => {
|
||||||
|
const { pinia, store } = setup()
|
||||||
|
store.createTask({ title: '待办任务' })
|
||||||
|
const wrapper = mountBoard(pinia)
|
||||||
|
|
||||||
|
await wrapper.find('article').trigger('click')
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
expect((wrapper.find('input[aria-label="标题"]').element as HTMLInputElement).value).toBe(
|
||||||
|
'待办任务',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('保存后看板同步更新', async () => {
|
||||||
|
const { pinia, store } = setup()
|
||||||
|
store.createTask({ title: '旧标题' })
|
||||||
|
const wrapper = mountBoard(pinia)
|
||||||
|
|
||||||
|
await wrapper.find('article').trigger('click')
|
||||||
|
await nextTick()
|
||||||
|
await wrapper.find('input[aria-label="标题"]').setValue('新标题')
|
||||||
|
|
||||||
|
const saveButton = wrapper.findAll('button').find((node) => node.text() === '保存')
|
||||||
|
await saveButton?.trigger('click')
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('新标题')
|
||||||
|
expect(store.tasks[0]?.title).toBe('新标题')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('取消后抽屉关闭且数据不变', async () => {
|
||||||
|
const { pinia, store } = setup()
|
||||||
|
store.createTask({ title: '原标题' })
|
||||||
|
const wrapper = mountBoard(pinia)
|
||||||
|
|
||||||
|
await wrapper.find('article').trigger('click')
|
||||||
|
await nextTick()
|
||||||
|
await wrapper.find('input[aria-label="标题"]').setValue('改了一半')
|
||||||
|
|
||||||
|
const cancelButton = wrapper.findAll('button').find((node) => node.text() === '取消')
|
||||||
|
await cancelButton?.trigger('click')
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
expect(wrapper.find('input[aria-label="标题"]').exists()).toBe(false)
|
||||||
|
expect(store.tasks[0]?.title).toBe('原标题')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('删除后抽屉关闭且任务从看板消失', async () => {
|
||||||
|
const { pinia, store } = setup()
|
||||||
|
store.createTask({ title: '要删除的任务' })
|
||||||
|
const wrapper = mountBoard(pinia)
|
||||||
|
|
||||||
|
await wrapper.find('article').trigger('click')
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
const deleteButton = wrapper.findAll('button').find((node) => node.text() === '删除')
|
||||||
|
await deleteButton?.trigger('click')
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
expect(store.tasks).toHaveLength(0)
|
||||||
|
expect(wrapper.find('input[aria-label="标题"]').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('BoardView 拖拽接线', () => {
|
||||||
|
it('列组件 emit move 时驱动 store 改变状态', async () => {
|
||||||
|
const { pinia, store } = setup()
|
||||||
|
const task = store.createTask({ title: 'A' })
|
||||||
|
const wrapper = mountBoard(pinia)
|
||||||
|
|
||||||
|
const column = wrapper.findAllComponents(BoardColumn)[0]
|
||||||
|
column?.vm.$emit('move', { id: task?.id ?? '', toColumn: 'doing', toIndex: 0 })
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
expect(store.byColumn.todo).toHaveLength(0)
|
||||||
|
expect(store.byColumn.doing.map((item) => item.title)).toEqual(['A'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('列内移动事件驱动顺序变化', async () => {
|
||||||
|
const { pinia, store } = setup()
|
||||||
|
const a = store.createTask({ title: 'A' })
|
||||||
|
store.createTask({ title: 'B' })
|
||||||
|
store.createTask({ title: 'C' })
|
||||||
|
expect(store.byColumn.todo.map((item) => item.title)).toEqual(['C', 'B', 'A'])
|
||||||
|
|
||||||
|
const wrapper = mountBoard(pinia)
|
||||||
|
const column = wrapper.findAllComponents(BoardColumn)[0]
|
||||||
|
column?.vm.$emit('move', { id: a?.id ?? '', toColumn: 'todo', toIndex: 0 })
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
expect(store.byColumn.todo.map((item) => item.title)).toEqual(['A', 'C', 'B'])
|
||||||
|
expect(wrapper.findAll('section')[0]?.text()).toContain('A')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('BoardView 筛选', () => {
|
||||||
|
it('按优先级筛选后只显示匹配卡片,列计数同步变化', async () => {
|
||||||
|
const { pinia, store } = setup()
|
||||||
|
store.createTask({ title: '高优先级', priority: 3 })
|
||||||
|
store.createTask({ title: '低优先级', priority: 1 })
|
||||||
|
const wrapper = mountBoard(pinia)
|
||||||
|
expect(wrapper.findAll('article')).toHaveLength(2)
|
||||||
|
|
||||||
|
useFilterStore().togglePriority(3)
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
expect(wrapper.findAll('article')).toHaveLength(1)
|
||||||
|
expect(wrapper.text()).toContain('高优先级')
|
||||||
|
expect(wrapper.text()).not.toContain('低优先级')
|
||||||
|
expect(wrapper.findAll('section header span')[0]?.text()).toBe('1')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('显示命中数量提示', async () => {
|
||||||
|
const { pinia, store } = setup()
|
||||||
|
store.createTask({ title: 'A', priority: 3 })
|
||||||
|
store.createTask({ title: 'B', priority: 1 })
|
||||||
|
const wrapper = mountBoard(pinia)
|
||||||
|
|
||||||
|
useFilterStore().togglePriority(3)
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('显示 1 / 2 个任务')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('清空筛选后恢复全部卡片', async () => {
|
||||||
|
const { pinia, store } = setup()
|
||||||
|
store.createTask({ title: 'A', priority: 3 })
|
||||||
|
store.createTask({ title: 'B', priority: 1 })
|
||||||
|
const wrapper = mountBoard(pinia)
|
||||||
|
|
||||||
|
const filters = useFilterStore()
|
||||||
|
filters.togglePriority(3)
|
||||||
|
await nextTick()
|
||||||
|
filters.clear()
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
expect(wrapper.findAll('article')).toHaveLength(2)
|
||||||
|
expect(wrapper.text()).not.toContain('显示 1 / 2')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('点击「逾期」只显示逾期任务', async () => {
|
||||||
|
const { pinia, store } = setup()
|
||||||
|
store.createTask({
|
||||||
|
title: '逾期任务',
|
||||||
|
dueDate: dayjs().subtract(1, 'day').format('YYYY-MM-DD'),
|
||||||
|
})
|
||||||
|
store.createTask({ title: '未来任务', dueDate: dayjs().add(3, 'day').format('YYYY-MM-DD') })
|
||||||
|
const wrapper = mountBoard(pinia)
|
||||||
|
|
||||||
|
const overdueButton = wrapper.findAll('button').find((node) => node.text().includes('逾期'))
|
||||||
|
await overdueButton?.trigger('click')
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('逾期任务')
|
||||||
|
expect(wrapper.text()).not.toContain('未来任务')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('列内渲染子集时给列组件传 sortDisabled(拖拽保护)', async () => {
|
||||||
|
const { pinia, store } = setup()
|
||||||
|
store.createTask({ title: 'A' })
|
||||||
|
const wrapper = mountBoard(pinia)
|
||||||
|
|
||||||
|
expect(wrapper.findAllComponents(BoardColumn)[0]?.props('sortDisabled')).toBe(false)
|
||||||
|
|
||||||
|
useFilterStore().togglePriority(3)
|
||||||
|
await nextTick()
|
||||||
|
expect(wrapper.findAllComponents(BoardColumn)[0]?.props('sortDisabled')).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('BoardView 归档', () => {
|
||||||
|
function archiveButton(wrapper: ReturnType<typeof mountBoard>) {
|
||||||
|
return wrapper.findAll('button').find((node) => node.text().includes('个已完成'))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createArchivable(store: ReturnType<typeof useTaskStore>) {
|
||||||
|
const task = store.createTask({ title: '很久前完成的任务', status: 'done' })
|
||||||
|
store.updateTask(task?.id ?? '', {
|
||||||
|
completedAt: dayjs().subtract(30, 'day').toISOString(),
|
||||||
|
})
|
||||||
|
return task
|
||||||
|
}
|
||||||
|
|
||||||
|
it('默认不显示归档任务', async () => {
|
||||||
|
const { pinia, store } = setup()
|
||||||
|
const task = await createArchivable(store)
|
||||||
|
store.archiveTasks([task?.id ?? ''])
|
||||||
|
|
||||||
|
const wrapper = mountBoard(pinia)
|
||||||
|
|
||||||
|
expect(wrapper.findAll('article')).toHaveLength(0)
|
||||||
|
expect(wrapper.text()).toContain('显示 0 / 1 个任务')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('开启「显示归档」后归档任务重新出现', async () => {
|
||||||
|
const { pinia, store } = setup()
|
||||||
|
const task = await createArchivable(store)
|
||||||
|
store.archiveTasks([task?.id ?? ''])
|
||||||
|
|
||||||
|
const wrapper = mountBoard(pinia)
|
||||||
|
const toggle = wrapper.findAll('button').find((node) => node.text() === '显示归档')
|
||||||
|
await toggle?.trigger('click')
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
expect(wrapper.findAll('article')).toHaveLength(1)
|
||||||
|
expect(wrapper.text()).toContain('很久前完成的任务')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('归档后给出可撤销提示,撤销后任务回到看板', async () => {
|
||||||
|
const { pinia, store } = setup()
|
||||||
|
await createArchivable(store)
|
||||||
|
const wrapper = mountBoard(pinia)
|
||||||
|
|
||||||
|
await archiveButton(wrapper)?.trigger('click')
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
expect(store.tasks[0]?.archivedAt).not.toBeNull()
|
||||||
|
expect(wrapper.findAll('article')).toHaveLength(0)
|
||||||
|
|
||||||
|
const toast = useToastStore()
|
||||||
|
expect(toast.toasts[0]?.action?.label).toBe('撤销')
|
||||||
|
|
||||||
|
toast.runAction(toast.toasts[0]?.id ?? '')
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
expect(store.tasks[0]?.archivedAt).toBeNull()
|
||||||
|
expect(wrapper.findAll('article')).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('隐藏归档时「已完成」列禁用列内排序', async () => {
|
||||||
|
const { pinia, store } = setup()
|
||||||
|
const task = await createArchivable(store)
|
||||||
|
store.archiveTasks([task?.id ?? ''])
|
||||||
|
|
||||||
|
const wrapper = mountBoard(pinia)
|
||||||
|
// 归档任务只可能在「已完成」列
|
||||||
|
expect(wrapper.findAllComponents(BoardColumn)[2]?.props('sortDisabled')).toBe(true)
|
||||||
|
expect(wrapper.findAllComponents(BoardColumn)[0]?.props('sortDisabled')).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('BoardView 快捷键', () => {
|
||||||
|
function pressKey(key: string, target: EventTarget = document): void {
|
||||||
|
target.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true }))
|
||||||
|
}
|
||||||
|
|
||||||
|
it('按 N 聚焦待办列的快速新增输入框', async () => {
|
||||||
|
const { pinia } = setup()
|
||||||
|
const wrapper = mountBoard(pinia)
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
pressKey('n')
|
||||||
|
expect(document.activeElement).toBe(wrapper.find('input[aria-label="添加任务"]').element)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('按 / 聚焦搜索框', async () => {
|
||||||
|
const { pinia } = setup()
|
||||||
|
const wrapper = mountBoard(pinia)
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
pressKey('/')
|
||||||
|
expect(document.activeElement).toBe(wrapper.find('input[aria-label="搜索任务"]').element)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('按 ? 打开快捷键帮助', async () => {
|
||||||
|
const { pinia } = setup()
|
||||||
|
const wrapper = mountBoard(pinia)
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
pressKey('?')
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('聚焦「待办」列的快速新增输入框')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('在输入框内按 N 不触发快捷键', async () => {
|
||||||
|
const { pinia } = setup()
|
||||||
|
const wrapper = mountBoard(pinia)
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
const search = wrapper.find('input[aria-label="搜索任务"]')
|
||||||
|
pressKey('n', search.element)
|
||||||
|
|
||||||
|
expect(document.activeElement).not.toBe(wrapper.find('input[aria-label="添加任务"]').element)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('面板打开时不响应快捷键', async () => {
|
||||||
|
const { pinia } = setup()
|
||||||
|
const wrapper = mountBoard(pinia)
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
await wrapper.find('button[aria-label="快捷键帮助"]').trigger('click')
|
||||||
|
await nextTick()
|
||||||
|
pressKey('n')
|
||||||
|
|
||||||
|
expect(document.activeElement).not.toBe(wrapper.find('input[aria-label="添加任务"]').element)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('按 Esc 关闭已打开的面板', async () => {
|
||||||
|
const { pinia } = setup()
|
||||||
|
const wrapper = mountBoard(pinia)
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
await wrapper.find('button[aria-label="快捷键帮助"]').trigger('click')
|
||||||
|
await nextTick()
|
||||||
|
expect(wrapper.text()).toContain('聚焦「待办」列的快速新增输入框')
|
||||||
|
|
||||||
|
pressKey('Escape')
|
||||||
|
await nextTick()
|
||||||
|
expect(wrapper.text()).not.toContain('聚焦「待办」列的快速新增输入框')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { storeToRefs } from 'pinia'
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
|
||||||
|
import BackupPanel from '@/components/BackupPanel.vue'
|
||||||
|
import BoardColumn from '@/components/BoardColumn.vue'
|
||||||
|
import BoardToolbar from '@/components/BoardToolbar.vue'
|
||||||
|
import SideDrawer from '@/components/SideDrawer.vue'
|
||||||
|
import TaskDetailDrawer from '@/components/TaskDetailDrawer.vue'
|
||||||
|
import { useKeyboardShortcuts } from '@/composables/useKeyboardShortcuts'
|
||||||
|
import { useFilterStore } from '@/stores/filters'
|
||||||
|
import { useTaskStore } from '@/stores/tasks'
|
||||||
|
import { useToastStore } from '@/stores/toast'
|
||||||
|
import { COLUMNS, type ColumnId } from '@/types/task'
|
||||||
|
import { findArchivableTasks } from '@/utils/archive'
|
||||||
|
import { filterTasks } from '@/utils/filter'
|
||||||
|
import { groupByColumn } from '@/utils/sortOrder'
|
||||||
|
|
||||||
|
const store = useTaskStore()
|
||||||
|
const filters = useFilterStore()
|
||||||
|
const toast = useToastStore()
|
||||||
|
const { notice } = storeToRefs(store)
|
||||||
|
const { filter } = storeToRefs(filters)
|
||||||
|
|
||||||
|
const SHORTCUTS = [
|
||||||
|
{ keys: 'N', description: '聚焦「待办」列的快速新增输入框' },
|
||||||
|
{ keys: '/', description: '聚焦搜索框' },
|
||||||
|
{ keys: '?', description: '打开本帮助' },
|
||||||
|
{ keys: 'Esc', description: '关闭当前面板' },
|
||||||
|
] as const
|
||||||
|
|
||||||
|
/** 详情抽屉当前编辑的任务 id;任务被删除后自动变为 null,抽屉随之关闭 */
|
||||||
|
const editingId = ref<string | null>(null)
|
||||||
|
const editingTask = computed(() => store.tasks.find((task) => task.id === editingId.value) ?? null)
|
||||||
|
|
||||||
|
const backupOpen = ref(false)
|
||||||
|
const helpOpen = ref(false)
|
||||||
|
|
||||||
|
const toolbarRef = ref<InstanceType<typeof BoardToolbar> | null>(null)
|
||||||
|
const columnRefs = new Map<number, InstanceType<typeof BoardColumn>>()
|
||||||
|
|
||||||
|
function setColumnRef(element: unknown, index: number): void {
|
||||||
|
if (element) columnRefs.set(index, element as InstanceType<typeof BoardColumn>)
|
||||||
|
else columnRefs.delete(index)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 实际渲染的看板:先按条件筛选,再按列分组,列计数随之反映结果 */
|
||||||
|
const visibleColumns = computed(() => groupByColumn(filterTasks(store.tasks, filter.value)))
|
||||||
|
|
||||||
|
const visibleCount = computed(() =>
|
||||||
|
COLUMNS.reduce((total, column) => total + visibleColumns.value[column.id].length, 0),
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 每列被隐藏的数量(被筛选掉 + 已归档)。
|
||||||
|
* 大于 0 说明该列渲染的是子集,此时必须禁用列内排序,
|
||||||
|
* 否则 Sortable 给出的子集下标会把全量顺序写坏。
|
||||||
|
*/
|
||||||
|
const hiddenCounts = computed(() => {
|
||||||
|
const result = {} as Record<ColumnId, number>
|
||||||
|
for (const column of COLUMNS) {
|
||||||
|
result[column.id] = store.byColumn[column.id].length - visibleColumns.value[column.id].length
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
})
|
||||||
|
|
||||||
|
const hiddenTotal = computed(() =>
|
||||||
|
COLUMNS.reduce((total, column) => total + hiddenCounts.value[column.id], 0),
|
||||||
|
)
|
||||||
|
|
||||||
|
const archivableTasks = computed(() => findArchivableTasks(store.tasks))
|
||||||
|
|
||||||
|
function archiveCompleted(): void {
|
||||||
|
const targets = archivableTasks.value
|
||||||
|
if (targets.length === 0) return
|
||||||
|
|
||||||
|
const ids = targets.map((task) => task.id)
|
||||||
|
const count = store.archiveTasks(ids)
|
||||||
|
if (count === 0) return
|
||||||
|
|
||||||
|
toast.show({
|
||||||
|
message: `已归档 ${count} 个已完成任务`,
|
||||||
|
action: { label: '撤销', run: () => store.unarchiveTasks(ids) },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function anyPanelOpen(): boolean {
|
||||||
|
return editingId.value !== null || backupOpen.value || helpOpen.value
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 有面板打开时不响应全局快捷键,避免误触 */
|
||||||
|
function whenNoPanel(action: () => void): () => void {
|
||||||
|
return () => {
|
||||||
|
if (anyPanelOpen()) return
|
||||||
|
action()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useKeyboardShortcuts({
|
||||||
|
n: whenNoPanel(() => columnRefs.get(0)?.focusQuickAdd()),
|
||||||
|
'/': whenNoPanel(() => toolbarRef.value?.focusSearch()),
|
||||||
|
'?': whenNoPanel(() => {
|
||||||
|
helpOpen.value = true
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
function toggleTask(id: string): void {
|
||||||
|
const task = store.tasks.find((item) => item.id === id)
|
||||||
|
if (!task) return
|
||||||
|
store.updateTask(id, { status: task.status === 'done' ? 'todo' : 'done' })
|
||||||
|
}
|
||||||
|
|
||||||
|
function openTask(id: string): void {
|
||||||
|
editingId.value = id
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeDetail(): void {
|
||||||
|
editingId.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleMove(payload: { id: string; toColumn: ColumnId; toIndex: number }): void {
|
||||||
|
store.moveTask(payload.id, payload.toColumn, payload.toIndex)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex min-h-0 flex-1 flex-col gap-4 p-4 md:p-6">
|
||||||
|
<BoardToolbar
|
||||||
|
ref="toolbarRef"
|
||||||
|
@open-backup="backupOpen = true"
|
||||||
|
@open-help="helpOpen = true"
|
||||||
|
@archive="archiveCompleted"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<p
|
||||||
|
v-if="notice"
|
||||||
|
class="rounded-md border border-amber-300 bg-amber-50 px-3 py-2 text-sm text-amber-800"
|
||||||
|
>
|
||||||
|
{{ notice }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p v-if="hiddenTotal > 0" class="text-xs text-slate-500">
|
||||||
|
显示 {{ visibleCount }} / {{ store.tasks.length }} 个任务
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="grid min-h-0 flex-1 gap-4 overflow-y-auto md:grid-cols-3 md:overflow-hidden">
|
||||||
|
<BoardColumn
|
||||||
|
v-for="(column, index) in COLUMNS"
|
||||||
|
:key="column.id"
|
||||||
|
:ref="(element) => setColumnRef(element, index)"
|
||||||
|
:column="column"
|
||||||
|
:tasks="visibleColumns[column.id]"
|
||||||
|
:sort-disabled="hiddenCounts[column.id] > 0"
|
||||||
|
@toggle="toggleTask"
|
||||||
|
@open="openTask"
|
||||||
|
@move="handleMove"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TaskDetailDrawer :task="editingTask" @close="closeDetail" />
|
||||||
|
|
||||||
|
<BackupPanel :open="backupOpen" @close="backupOpen = false" />
|
||||||
|
|
||||||
|
<SideDrawer :open="helpOpen" title="快捷键" @close="helpOpen = false">
|
||||||
|
<dl class="flex flex-col gap-3 text-sm">
|
||||||
|
<div v-for="item in SHORTCUTS" :key="item.keys" class="flex items-center gap-3">
|
||||||
|
<dt
|
||||||
|
class="w-14 shrink-0 rounded border border-slate-300 bg-slate-50 px-2 py-0.5 text-center font-mono text-xs text-slate-600"
|
||||||
|
>
|
||||||
|
{{ item.keys }}
|
||||||
|
</dt>
|
||||||
|
<dd class="text-slate-600">{{ item.description }}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
<p class="mt-4 text-xs text-slate-400">在输入框内输入时不会触发快捷键。</p>
|
||||||
|
</SideDrawer>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
import { mount } from '@vue/test-utils'
|
||||||
|
import dayjs from 'dayjs'
|
||||||
|
import { createPinia, setActivePinia } from 'pinia'
|
||||||
|
import { beforeEach, describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
import { createMemoryTaskRepository } from '@/data/taskRepository'
|
||||||
|
import { useTaskStore } from '@/stores/tasks'
|
||||||
|
import type { Task } from '@/types/task'
|
||||||
|
|
||||||
|
import TodayView from './TodayView.vue'
|
||||||
|
|
||||||
|
const today = dayjs().format('YYYY-MM-DD')
|
||||||
|
const yesterday = dayjs().subtract(1, 'day').format('YYYY-MM-DD')
|
||||||
|
const tomorrow = dayjs().add(1, 'day').format('YYYY-MM-DD')
|
||||||
|
|
||||||
|
function setup(seed: Array<Partial<Task> & { title: string }> = []) {
|
||||||
|
const pinia = createPinia()
|
||||||
|
setActivePinia(pinia)
|
||||||
|
const store = useTaskStore()
|
||||||
|
store.init({ repository: createMemoryTaskRepository(), crossTab: false })
|
||||||
|
|
||||||
|
for (const item of seed) {
|
||||||
|
const created = store.createTask({ title: item.title, status: item.status ?? 'todo' })
|
||||||
|
if (!created) continue
|
||||||
|
const patch: Partial<Task> = {}
|
||||||
|
if (item.dueDate !== undefined) patch.dueDate = item.dueDate
|
||||||
|
if (item.priority !== undefined) patch.priority = item.priority
|
||||||
|
if (item.status !== undefined) patch.status = item.status
|
||||||
|
if (Object.keys(patch).length > 0) store.updateTask(created.id, patch)
|
||||||
|
}
|
||||||
|
|
||||||
|
const wrapper = mount(TodayView, {
|
||||||
|
global: { plugins: [pinia], stubs: { teleport: true } },
|
||||||
|
attachTo: document.body,
|
||||||
|
})
|
||||||
|
return { store, wrapper }
|
||||||
|
}
|
||||||
|
|
||||||
|
function sectionTitles(wrapper: ReturnType<typeof mount>): string[] {
|
||||||
|
return wrapper.findAll('h2').map((node) => node.text())
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('TodayView 顶部', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
setActivePinia(createPinia())
|
||||||
|
})
|
||||||
|
|
||||||
|
it('显示标题与当天日期', () => {
|
||||||
|
const { wrapper } = setup()
|
||||||
|
expect(wrapper.find('h1').text()).toBe('今日')
|
||||||
|
expect(wrapper.text()).toContain(dayjs().format('YYYY年M月D日'))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('显示四张统计卡片', () => {
|
||||||
|
const { wrapper } = setup()
|
||||||
|
const labels = wrapper.findAll('dt').map((node) => node.text())
|
||||||
|
expect(labels).toEqual(['今日待办', '逾期', '今天到期', '本周完成'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('统计卡片数值正确', () => {
|
||||||
|
const { wrapper } = setup([
|
||||||
|
{ title: '逾期的', dueDate: yesterday },
|
||||||
|
{ title: '今天到期', dueDate: today },
|
||||||
|
{ title: '进行中', status: 'doing' },
|
||||||
|
{ title: '已完成的', status: 'done' },
|
||||||
|
])
|
||||||
|
|
||||||
|
const values = wrapper.findAll('dd').map((node) => node.text())
|
||||||
|
// 今日待办 3(逾期 + 今天到期 + 进行中),逾期 1,今天到期 1,本周完成 1
|
||||||
|
expect(values).toEqual(['3', '1', '1', '1'])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('TodayView 分组', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
setActivePinia(createPinia())
|
||||||
|
})
|
||||||
|
|
||||||
|
it('按逾期 / 今天到期 / 进行中分组', () => {
|
||||||
|
const { wrapper } = setup([
|
||||||
|
{ title: '逾期的', dueDate: yesterday },
|
||||||
|
{ title: '今天到期', dueDate: today },
|
||||||
|
{ title: '进行中', status: 'doing' },
|
||||||
|
])
|
||||||
|
|
||||||
|
const titles = sectionTitles(wrapper)
|
||||||
|
expect(titles).toHaveLength(3)
|
||||||
|
expect(titles[0]).toContain('已逾期')
|
||||||
|
expect(titles[1]).toContain('今天到期')
|
||||||
|
expect(titles[2]).toContain('进行中')
|
||||||
|
|
||||||
|
// 每组各 1 条
|
||||||
|
const badges = wrapper.findAll('[data-role="section-count"]').map((node) => node.text())
|
||||||
|
expect(badges).toEqual(['1', '1', '1'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('没有任务的分组不渲染', () => {
|
||||||
|
const { wrapper } = setup([{ title: '只有进行中', status: 'doing' }])
|
||||||
|
const titles = sectionTitles(wrapper)
|
||||||
|
expect(titles).toHaveLength(1)
|
||||||
|
expect(titles[0]).toContain('进行中')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('已完成任务不出现在任何分组', () => {
|
||||||
|
const { wrapper } = setup([{ title: '已完成', status: 'done', dueDate: yesterday }])
|
||||||
|
expect(wrapper.findAll('article')).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('未开始且无截止日期的任务不出现', () => {
|
||||||
|
const { wrapper } = setup([{ title: '以后再说' }])
|
||||||
|
expect(wrapper.findAll('article')).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('未来截止但未开始的任务不出现', () => {
|
||||||
|
const { wrapper } = setup([{ title: '下周的事', dueDate: tomorrow }])
|
||||||
|
expect(wrapper.findAll('article')).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('同一任务只出现一次', () => {
|
||||||
|
const { wrapper } = setup([
|
||||||
|
{ title: '既逾期又进行中', dueDate: yesterday, status: 'doing' },
|
||||||
|
])
|
||||||
|
expect(wrapper.findAll('article')).toHaveLength(1)
|
||||||
|
expect(sectionTitles(wrapper)[0]).toContain('已逾期')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('组内按优先级降序排列', () => {
|
||||||
|
const { wrapper } = setup([
|
||||||
|
{ title: '低优先级', dueDate: yesterday, priority: 1 },
|
||||||
|
{ title: '高优先级', dueDate: yesterday, priority: 3 },
|
||||||
|
])
|
||||||
|
|
||||||
|
const titles = wrapper.findAll('article h3').map((node) => node.text())
|
||||||
|
expect(titles).toEqual(['高优先级', '低优先级'])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('TodayView 空状态', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
setActivePinia(createPinia())
|
||||||
|
})
|
||||||
|
|
||||||
|
it('没有待办时显示空状态', () => {
|
||||||
|
const { wrapper } = setup()
|
||||||
|
expect(wrapper.text()).toContain('今天没有待办事项')
|
||||||
|
expect(wrapper.findAll('article')).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('有待办时不显示空状态', () => {
|
||||||
|
const { wrapper } = setup([{ title: '有事做', status: 'doing' }])
|
||||||
|
expect(wrapper.text()).not.toContain('今天没有待办事项')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('TodayView 交互', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
setActivePinia(createPinia())
|
||||||
|
})
|
||||||
|
|
||||||
|
it('点击完成按钮后任务从今日视图消失', async () => {
|
||||||
|
const { store, wrapper } = setup([{ title: '要做的事', status: 'doing' }])
|
||||||
|
expect(wrapper.findAll('article')).toHaveLength(1)
|
||||||
|
|
||||||
|
await wrapper.find('article button').trigger('click')
|
||||||
|
await wrapper.vm.$nextTick()
|
||||||
|
|
||||||
|
expect(store.tasks[0]?.status).toBe('done')
|
||||||
|
expect(wrapper.findAll('article')).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('点击卡片打开详情抽屉', async () => {
|
||||||
|
const { wrapper } = setup([{ title: '要做的事', status: 'doing' }])
|
||||||
|
|
||||||
|
await wrapper.find('article').trigger('click')
|
||||||
|
await wrapper.vm.$nextTick()
|
||||||
|
|
||||||
|
expect(
|
||||||
|
(wrapper.find('input[aria-label="标题"]').element as HTMLInputElement).value,
|
||||||
|
).toBe('要做的事')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import dayjs from 'dayjs'
|
||||||
|
import { storeToRefs } from 'pinia'
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
|
||||||
|
import TaskCard from '@/components/TaskCard.vue'
|
||||||
|
import TaskDetailDrawer from '@/components/TaskDetailDrawer.vue'
|
||||||
|
import { useTaskStore } from '@/stores/tasks'
|
||||||
|
import { computeStats } from '@/utils/stats'
|
||||||
|
import { buildTodayBuckets, countTodayTasks } from '@/utils/today'
|
||||||
|
|
||||||
|
const WEEKDAYS = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'] as const
|
||||||
|
|
||||||
|
/** 用完整类名字符串,避免 Tailwind 扫描不到动态拼接的类 */
|
||||||
|
const TONES = {
|
||||||
|
rose: { dot: 'bg-rose-500', badge: 'bg-rose-100 text-rose-700' },
|
||||||
|
amber: { dot: 'bg-amber-500', badge: 'bg-amber-100 text-amber-700' },
|
||||||
|
sky: { dot: 'bg-sky-500', badge: 'bg-sky-100 text-sky-700' },
|
||||||
|
} as const
|
||||||
|
|
||||||
|
const store = useTaskStore()
|
||||||
|
const { tasks } = storeToRefs(store)
|
||||||
|
|
||||||
|
const todayLabel = computed(() => {
|
||||||
|
const now = dayjs()
|
||||||
|
return `${now.format('YYYY年M月D日')} ${WEEKDAYS[now.day()]}`
|
||||||
|
})
|
||||||
|
|
||||||
|
const buckets = computed(() => buildTodayBuckets(tasks.value))
|
||||||
|
const stats = computed(() => computeStats(tasks.value))
|
||||||
|
const todayCount = computed(() => countTodayTasks(buckets.value))
|
||||||
|
|
||||||
|
const statCards = computed(() => [
|
||||||
|
{ label: '今日待办', value: todayCount.value },
|
||||||
|
{ label: '逾期', value: stats.value.overdue },
|
||||||
|
{ label: '今天到期', value: stats.value.dueToday },
|
||||||
|
{ label: '本周完成', value: stats.value.completedThisWeek },
|
||||||
|
])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 空分组直接过滤掉。
|
||||||
|
* 不能把 v-if 和 v-for 写在同一元素上 —— Vue 3 里 v-if 优先级更高,
|
||||||
|
* 拿不到 v-for 的作用域变量,组件会直接渲染失败。
|
||||||
|
*/
|
||||||
|
const sections = computed(() =>
|
||||||
|
[
|
||||||
|
{
|
||||||
|
key: 'overdue',
|
||||||
|
title: '已逾期',
|
||||||
|
hint: '建议优先处理',
|
||||||
|
tone: 'rose' as const,
|
||||||
|
items: buckets.value.overdue,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'dueToday',
|
||||||
|
title: '今天到期',
|
||||||
|
hint: '今天要完成',
|
||||||
|
tone: 'amber' as const,
|
||||||
|
items: buckets.value.dueToday,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'inProgress',
|
||||||
|
title: '进行中',
|
||||||
|
hint: '已开始,尚无紧迫截止日期',
|
||||||
|
tone: 'sky' as const,
|
||||||
|
items: buckets.value.inProgress,
|
||||||
|
},
|
||||||
|
].filter((section) => section.items.length > 0),
|
||||||
|
)
|
||||||
|
|
||||||
|
const editingId = ref<string | null>(null)
|
||||||
|
const editingTask = computed(() => store.tasks.find((task) => task.id === editingId.value) ?? null)
|
||||||
|
|
||||||
|
function toggleTask(id: string): void {
|
||||||
|
const task = store.tasks.find((item) => item.id === id)
|
||||||
|
if (!task) return
|
||||||
|
store.updateTask(id, { status: task.status === 'done' ? 'todo' : 'done' })
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex min-h-0 flex-1 flex-col gap-5 overflow-y-auto p-4 md:p-6">
|
||||||
|
<header class="flex flex-col gap-1">
|
||||||
|
<h1 class="text-xl font-semibold text-slate-800 md:text-2xl">今日</h1>
|
||||||
|
<p class="text-sm text-slate-500">{{ todayLabel }}</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<dl class="grid grid-cols-2 gap-3 md:grid-cols-4">
|
||||||
|
<div
|
||||||
|
v-for="card in statCards"
|
||||||
|
:key="card.label"
|
||||||
|
class="rounded-lg bg-white px-4 py-3 shadow-sm"
|
||||||
|
>
|
||||||
|
<dt class="text-xs text-slate-500">{{ card.label }}</dt>
|
||||||
|
<dd class="mt-1 text-2xl font-semibold text-slate-800">{{ card.value }}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<p
|
||||||
|
v-if="todayCount === 0"
|
||||||
|
class="rounded-lg bg-white px-4 py-12 text-center text-sm text-slate-400 shadow-sm"
|
||||||
|
>
|
||||||
|
今天没有待办事项
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<section
|
||||||
|
v-for="section in sections"
|
||||||
|
:key="section.key"
|
||||||
|
class="flex flex-col gap-2"
|
||||||
|
>
|
||||||
|
<h2 class="flex flex-wrap items-center gap-2 text-sm font-medium text-slate-600">
|
||||||
|
<span class="size-2 rounded-full" :class="TONES[section.tone].dot" />
|
||||||
|
{{ section.title }}
|
||||||
|
<span
|
||||||
|
class="rounded-full px-2 py-0.5 text-xs"
|
||||||
|
:class="TONES[section.tone].badge"
|
||||||
|
data-role="section-count"
|
||||||
|
>
|
||||||
|
{{ section.items.length }}
|
||||||
|
</span>
|
||||||
|
<span class="text-xs font-normal text-slate-400">{{ section.hint }}</span>
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<TaskCard
|
||||||
|
v-for="task in section.items"
|
||||||
|
:key="task.id"
|
||||||
|
:task="task"
|
||||||
|
@toggle="toggleTask"
|
||||||
|
@open="editingId = $event"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<TaskDetailDrawer :task="editingTask" @close="editingId = null" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
|
"types": ["vite/client", "vite-plugin-pwa/client"],
|
||||||
|
"allowArbitraryExtensions": true,
|
||||||
|
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
},
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.e2e.tsbuildinfo",
|
||||||
|
"target": "es2023",
|
||||||
|
"lib": ["ES2023", "DOM"],
|
||||||
|
"types": ["node"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"noEmit": true,
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
},
|
||||||
|
"include": ["e2e/**/*.ts", "playwright.config.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" },
|
||||||
|
{ "path": "./tsconfig.e2e.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||||
|
"target": "es2023",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"types": ["node"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"module": "nodenext",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { fileURLToPath, URL } from 'node:url'
|
||||||
|
|
||||||
|
import tailwindcss from '@tailwindcss/vite'
|
||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
import { VitePWA } from 'vite-plugin-pwa'
|
||||||
|
import { defineConfig } from 'vitest/config'
|
||||||
|
|
||||||
|
// https://vite.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [
|
||||||
|
vue(),
|
||||||
|
tailwindcss(),
|
||||||
|
VitePWA({
|
||||||
|
// prompt:由应用决定何时提示用户更新,避免刷新时打断正在编辑的操作
|
||||||
|
registerType: 'prompt',
|
||||||
|
// 注册逻辑写在 PwaUpdatePrompt 组件里,不让插件自动注入
|
||||||
|
injectRegister: null,
|
||||||
|
includeAssets: ['favicon.svg', 'apple-touch-icon.png'],
|
||||||
|
manifest: {
|
||||||
|
name: '看板 · 个人任务管理',
|
||||||
|
short_name: '看板',
|
||||||
|
description: '轻量的个人看板工具,数据保存在本机浏览器',
|
||||||
|
lang: 'zh-CN',
|
||||||
|
start_url: '/',
|
||||||
|
scope: '/',
|
||||||
|
display: 'standalone',
|
||||||
|
background_color: '#f1f5f9',
|
||||||
|
theme_color: '#0284c7',
|
||||||
|
categories: ['productivity'],
|
||||||
|
icons: [
|
||||||
|
{ src: 'pwa-192x192.png', sizes: '192x192', type: 'image/png' },
|
||||||
|
{ src: 'pwa-512x512.png', sizes: '512x512', type: 'image/png' },
|
||||||
|
{
|
||||||
|
src: 'maskable-512x512.png',
|
||||||
|
sizes: '512x512',
|
||||||
|
type: 'image/png',
|
||||||
|
purpose: 'maskable',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
shortcuts: [
|
||||||
|
{ name: '今日', short_name: '今日', description: '查看今天要做的事', url: '/today' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
workbox: {
|
||||||
|
globPatterns: ['**/*.{js,css,html,svg,png,ico,webmanifest}'],
|
||||||
|
// 前端路由:离线时任何路径都回落到 index.html
|
||||||
|
navigateFallback: 'index.html',
|
||||||
|
cleanupOutdatedCaches: true,
|
||||||
|
clientsClaim: true,
|
||||||
|
// 更新由用户确认后再生效
|
||||||
|
skipWaiting: false,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
test: {
|
||||||
|
environment: 'jsdom',
|
||||||
|
include: ['src/**/*.spec.ts'],
|
||||||
|
coverage: {
|
||||||
|
provider: 'v8',
|
||||||
|
reporter: ['text'],
|
||||||
|
include: ['src/**/*.{ts,vue}'],
|
||||||
|
exclude: ['src/**/*.spec.ts', 'src/main.ts', 'src/types/**', 'src/router/**'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user