4 Commits
Author SHA1 Message Date
cheney e1e0880ce6 ci: dev-* 分支纳入 docker-build 触发范围 (构建/部署/数据库自动升级)
Docker Build and Push / build-image (push) Successful in 6m53s
- docker-build.yml: push branches 增加 'dev-*', 与 main/master 同环境部署
- Apply DB migrations 步骤加 migrate status 输出并强调在 Remove old container 之前执行
- readme: 新增「分支与 CI/CD」章节, 说明分支触发矩阵与数据库升级约定
2026-10-22 15:20:28 +08:00
cheney 9b4705870d docs(V0.2): 新增设计文档 / 数据库设计文档 / 界面设计静态原型
- 设计文档.md: 架构与分层、目录结构、周期口径与边界规则、前端组件与交互、
  后端接口清单、关键流程时序、非功能设计、测试验证、风险与演进
- 数据库设计文档.md: ER、5 张表逐字段、索引与外键级联、关键设计决策
  (date 用 VARCHAR(10) 的原因 / 不冗余计数 / seq 幂等 / 归档语义 / 模板 JSON)、
  容量估算、典型 SQL、迁移与回滚、V0.3 预留
- 界面设计.html: 420x820 静态高保真原型 8 屏 (打卡/日历/清单/新建弹窗/
  完成特效/明天计划视图/清单完成/空态) + 设计规范、组件状态表、动效与适配约定
2026-10-22 14:12:24 +08:00
cheney 004f8c262d feat(V0.2): 打卡 / 日历 / 清单模块
- 数据: Habit/CheckIn/CheckList/CheckListItem/CheckListTemplate + 迁移 + 重新生成 client
- 后端: server/checkin/period.js 周期计算(日/周/月/年, 周一起始, Asia/Shanghai)
        /api/checkin/* 打卡项CRUD/打卡/撤销/补打/某日详情/月历
        /api/checklists/* 清单与检查项CRUD/勾选/全选/复制/模板(系统预置出差·回老家)
- 前端: 底部新增「打卡」Tab -> /checkin, 页内三视图
        环形进度 RingProgress + 完成特效(粒子/光晕/触感, 可关闭)
        打卡视图: 待办列表/乐观更新/撤销/新建编辑
        日历视图: 月历网格 + 今天·昨天·明天 + 日期标记/完成率
        清单视图: 场景清单/勾选进度条/模板生成/另存模板/归档
- 文档: docs/V0.2/需求文档.md 补充实现约定(附录B)与接口清单
2026-10-22 11:37:16 +08:00
cheney 54a3224f00 docs(V0.2): 新增打卡/日历/清单模块需求文档 (总体描述 + 分类编号需求点 + 里程碑 + 数据实体草案) 2026-10-22 10:03:09 +08:00
37 changed files with 12901 additions and 59 deletions
+14 -4
View File
@@ -1,13 +1,17 @@
name: Docker Build and Push
# 仅 main 分支 push 触发, 其他分支不触发
# 每次主分支提交: 构建前端 + 生成 OTA 升级包 (签名) → 打进 Docker 镜像 → 部署
# main / master / dev-* 分支 push 触发, 其他分支不触发
# 每次提交: 构建前端 + 生成 OTA 升级包 (签名) → 打进 Docker 镜像 → 自动升级数据库 → 部署
# OTA 随镜像发布: 容器内 OTA_PACKAGE_DIR=/app/ota-package 指向打包的 ota-package/
#
# dev-* (如 dev-0.1 / dev-0.2) 与 main/master 部署到同一套环境 (容器 iboard, 宿主 3001),
# 即开发分支推送 = 直接发布: 数据库共用同一个库, migrate deploy 在启新容器前执行 (幂等).
on:
push:
branches:
- main
- master
- 'dev-*'
jobs:
build-image:
@@ -57,10 +61,16 @@ jobs:
run: docker images
- name: Apply DB migrations
# 仓库内的 prisma 迁移自动应用 (migrate deploy 幂等, 无新迁移时为 no-op)
# 数据库自动升级: 按 prisma/migrations/ 下的迁移顺序应用 (dev-* 与 main/master 共库)
# migrate deploy 幂等, 无新迁移时为 no-op
# 位置关键: 在 "Remove old container" 之前 —— 建表/改表成功后才切换流量,
# 迁移失败则整个 workflow 中断, 不会用新镜像顶掉仍在运行的旧容器
env:
DATABASE_URL: ${{ secrets.DATABASE_URL }}
run: npx prisma migrate deploy
run: |
set -euo pipefail
npx prisma migrate status || true
npx prisma migrate deploy
- name: Remove old container
run: docker rm -vf iboard || true
+2
View File
@@ -45,3 +45,5 @@ cache/apizero/
.cache/
# 米家凭证缓存 (含 token, 绝不能提交)
server/mihome/.cache/
# 工作区记忆/会话数据 (本地私有, 不入库)
.workbuddy/
+325
View File
@@ -0,0 +1,325 @@
# iBoard V0.2 数据库设计文档
| 项 | 内容 |
| --- | --- |
| 版本 | V0.2 |
| 文档状态 | 已实现(与代码一致) |
| 数据库 | MariaDB / MySQL 8(Prisma `provider = "mysql"`) |
| 字符集 | `utf8mb4 / utf8mb4_unicode_ci` |
| 时区约定 | 应用层统一 `Asia/Shanghai`,库内 `DATETIME` 存 UTC |
| 相关文件 | `prisma/schema.prisma`、`prisma/migrations/20261022120000_add_checkin_checklist/migration.sql` |
| 关联文档 | `需求文档.md`、`设计文档.md`、`界面设计.html` |
---
## 1. 概述
V0.2 新增「打卡 / 日历 / 清单」三个功能域,共引入 **5 张表**:
| 表 | 作用 | 归属域 |
| --- | --- | --- |
| `Habit` | 打卡项(每天/每周/每月/每年 n 次的目标定义) | 打卡 |
| `CheckIn` | 打卡记录(一次打卡 = 一条) | 打卡 |
| `CheckList` | 检查清单(出差/回老家等场景的一次性清单) | 清单 |
| `CheckListItem` | 检查项(勾选即完成) | 清单 |
| `CheckListTemplate` | 清单模板(系统预置 + 用户自定义) | 清单 |
日历视图**不新增表**:完全由 `Habit` + `CheckIn` 按月聚合得出(见 §7.3)。
### 1.1 设计原则
1. **记录为真,进度不落库。** 环形进度、完成率、streak 一律由 `CheckIn` 现场聚合,不在 `Habit` 上冗余 `doneCount` 字段——避免并发计数不一致、以及用户中途把「每周 3 次」改成「每周 5 次」后需要回填历史的问题。
2. **日期以本地日历日字符串存储。** `CheckIn.date` 用 `VARCHAR(10)` 存 `"YYYY-MM-DD"`(Asia/Shanghai 下的日历日),而不是 `DATETIME`:范围查询走索引、字典序等于时间序、天然幂等边界,且彻底规避「UTC 存 + 本地读」导致的跨零点归属错误。
3. **软删除优先。** 打卡项删除默认走 `archived` 归档(保留历史统计),只有显式 `?hard=1` 才物理删除。
4. **一切按 `userId` 隔离。** 所有业务表都带 `userId`,`Habit`/`CheckIn`/`CheckList`/`CheckListTemplate` 外键 `ON DELETE CASCADE` 到 `User`。
5. **迁移只增不改。** 新增需求一律新迁移文件,不修改已执行的迁移(部署走 `prisma migrate deploy`)。
---
## 2. 概念模型(ER)
```
┌──────────┐
│ User │ (既有表, 本次仅新增反向关系)
└────┬─────┘
┌───────────────┼────────────────────┬─────────────────┐
│ │ │ │
┌────▼────┐ ┌────▼────┐ ┌─────▼─────┐ ┌──────▼──────┐
│ Habit │1───*│ CheckIn │ │ CheckList │1─* │CheckListItem│
└─────────┘ └─────────┘ └───────────┘ └─────────────┘
打卡项 打卡记录 场景清单 检查项
(周期+目标n) (habitId,date,seq) (done, doneAt)
▲
│ 生成(复制条目, 不建外键)
┌─────┴──────────┐
│CheckListTemplate│
│ (builtin/自定义) │
└────────────────┘
```
基数说明:
| 关系 | 基数 | 级联 |
| --- | --- | --- |
| User → Habit | 1 : N | Cascade |
| User → CheckIn | 1 : N | Cascade |
| Habit → CheckIn | 1 : N | Cascade |
| User → CheckList | 1 : N | Cascade |
| CheckList → CheckListItem | 1 : N | Cascade |
| User → CheckListTemplate | 1 : N(可空,系统预置为空) | Cascade |
| CheckListTemplate → CheckList | 无外键,创建时**复制**条目内容 | — |
---
## 3. 表结构
### 3.1 `Habit` — 打卡项
周期目标的**定义**表,不含任何计数。
| 字段 | 类型 | 空 | 默认 | 说明 |
| --- | --- | --- | --- | --- |
| `id` | INT PK | 否 | AUTO_INCREMENT | 主键 |
| `userId` | INT FK | 否 | — | 所属用户 |
| `name` | VARCHAR(191) | 否 | — | 名称,接口校验 1~20 字 |
| `period` | VARCHAR(191) | 否 | `'day'` | `day` \| `week` \| `month` \| `year` |
| `target` | INT | 否 | `1` | 周期内目标次数 n(校验 ≥1) |
| `color` | VARCHAR(191) | 否 | `'#1677ff'` | 环形与标记主色(7 色预设之一) |
| `icon` | VARCHAR(191) | 否 | `''` | emoji / 图标名,**V0.2 预留不展示** |
| `note` | VARCHAR(191) | 否 | `''` | 备注 |
| `archived` | BOOLEAN | 否 | `false` | 归档后不出现在待办,历史保留 |
| `sortOrder` | INT | 否 | `0` | 排序权重(升序) |
| `createdAt` | DATETIME(3) | 否 | now() | 用于「创建之前的日期不计入月历期望」 |
| `updatedAt` | DATETIME(3) | 否 | 自动 | — |
索引:`INDEX(userId, archived, sortOrder)` — 覆盖「取当前用户未归档打卡项」主查询。
### 3.2 `CheckIn` — 打卡记录
**一次打卡 = 一条记录**,同日多次用 `seq` 递增。
| 字段 | 类型 | 空 | 默认 | 说明 |
| --- | --- | --- | --- | --- |
| `id` | INT PK | 否 | AUTO_INCREMENT | 主键 |
| `habitId` | INT FK | 否 | — | 所属打卡项 |
| `userId` | INT FK | 否 | — | 冗余用户(用于单用户全量区间查询,避免 join) |
| `date` | VARCHAR(10) | 否 | — | 本地日历日 `"YYYY-MM-DD"` |
| `seq` | INT | 否 | `1` | 当日第几次(1..n,超额继续累加) |
| `note` | VARCHAR(191) | 否 | `''` | 单次备注(接口截断 200 字) |
| `source` | VARCHAR(191) | 否 | `'user'` | `user` \| `backfill`(补打) |
| `createdAt` | DATETIME(3) | 否 | now() | 打卡时刻(UTC) |
约束与索引:
| 名称 | 类型 | 字段 | 作用 |
| --- | --- | --- | --- |
| `CheckIn_habitId_date_seq_key` | **UNIQUE** | `(habitId, date, seq)` | 并发重复提交的幂等防线(冲突 → P2002 → 409) |
| `CheckIn_userId_date_idx` | INDEX | `(userId, date)` | 月历按月区间扫全量记录 |
| `CheckIn_habitId_date_idx` | INDEX | `(habitId, date)` | 单打卡项周期区间聚合 |
### 3.3 `CheckList` — 检查清单
| 字段 | 类型 | 空 | 默认 | 说明 |
| --- | --- | --- | --- | --- |
| `id` | INT PK | 否 | AUTO_INCREMENT | 主键 |
| `userId` | INT FK | 否 | — | 所属用户 |
| `title` | VARCHAR(191) | 否 | — | 清单名,如「出差 · 杭州」 |
| `scene` | VARCHAR(191) | 否 | `''` | 场景:`出差` \| `回老家` \| `其他` |
| `note` | VARCHAR(191) | 否 | `''` | 备注 |
| `archived` | BOOLEAN | 否 | `false` | 归档 |
| `createdAt` / `updatedAt` | DATETIME(3) | 否 | — | 列表默认按 `updatedAt` 倒序 |
索引:`INDEX(userId, archived)` — 对应「进行中 / 已归档」两个分段列表。
### 3.4 `CheckListItem` — 检查项
| 字段 | 类型 | 空 | 默认 | 说明 |
| --- | --- | --- | --- | --- |
| `id` | INT PK | 否 | AUTO_INCREMENT | 主键 |
| `listId` | INT FK | 否 | — | 所属清单 |
| `title` | VARCHAR(191) | 否 | — | 条目名(校验 ≤40 字) |
| `note` | VARCHAR(191) | 否 | `''` | 条目备注 |
| `done` | BOOLEAN | 否 | `false` | 是否已完成 |
| `doneAt` | DATETIME(3) | **是** | NULL | 勾选时刻;取消勾选置回 NULL |
| `sortOrder` | INT | 否 | `0` | 排序(升序,同值按 id) |
| `createdAt` / `updatedAt` | DATETIME(3) | 否 | — | — |
索引:`INDEX(listId, sortOrder)`。
### 3.5 `CheckListTemplate` — 清单模板
| 字段 | 类型 | 空 | 默认 | 说明 |
| --- | --- | --- | --- | --- |
| `id` | INT PK | 否 | AUTO_INCREMENT | 主键 |
| `userId` | INT FK | **是** | NULL | NULL = 系统预置(`builtin=true`) |
| `name` | VARCHAR(191) | 否 | — | 模板名 |
| `scene` | VARCHAR(191) | 否 | `''` | 场景标签 |
| `items` | **JSON** | 否 | — | `[{ title, note }]`,创建清单时展开为 `CheckListItem` |
| `builtin` | BOOLEAN | 否 | `false` | 系统预置:只读、不可删(接口层限制) |
| `createdAt` / `updatedAt` | DATETIME(3) | 否 | — | — |
索引:`INDEX(userId, builtin)` — 查询条件为 `builtin=true OR userId=me`。
系统预置数据(首次访问 `GET /api/checklists/templates` 时**幂等写入**):
| 模板 | 条目数 | 条目 |
| --- | --- | --- |
| 出差 | 11 | 身份证/证件、手机充电器+充电宝、笔记本电脑+电源、耳机、换洗衣物、洗漱包、常用药、雨伞、车票/机票/酒店确认、工牌/名片、报销单据 |
| 回老家 | 12 | 身份证/证件、钥匙、手机充电器+充电宝、换洗衣物、洗漱包、常用药、给家人的礼物/伴手礼、长辈的药品/营养品、口罩、现金零钱、车票/加油/行程确认、家里门窗水电检查 |
---
## 4. 外键与级联
| 子表.字段 | 父表.字段 | ON DELETE | ON UPDATE |
| --- | --- | --- | --- |
| `Habit.userId` | `User.id` | CASCADE | CASCADE |
| `CheckIn.habitId` | `Habit.id` | CASCADE | CASCADE |
| `CheckIn.userId` | `User.id` | CASCADE | CASCADE |
| `CheckList.userId` | `User.id` | CASCADE | CASCADE |
| `CheckListItem.listId` | `CheckList.id` | CASCADE | CASCADE |
| `CheckListTemplate.userId` | `User.id` | CASCADE | CASCADE |
删除链路:删用户 → 级联清 Habit/CheckIn/CheckList/Template;删 Habit → 清其全部 CheckIn;删 CheckList → 清其全部 Item。业务层删打卡项默认只置 `archived=true`,物理删需 `?hard=1`(此时先 `deleteMany` 记录再删主表)。
---
## 5. 关键设计决策
### 5.1 为什么 `date` 用 `VARCHAR(10)` 而不是 `DATETIME`
| 维度 | VARCHAR(10) 本地日历日 | DATETIME(UTC) |
| --- | --- | --- |
| 跨零点归属 | 明确:由应用层按 Asia/Shanghai 计算一次,落库即定 | 读时需要再转时区,易错 |
| 范围查询 | `date >= '2026-10-01' AND date <= '2026-10-31'` 直接走索引 | 需函数/边界换算,易失效 |
| 幂等边界 | `UNIQUE(habitId, date, seq)` 天然按「日」收敛 | 需按日截取 |
| 字典序 | 等于时间序,可安全字符串比较 | — |
| 存储 | 10 字节 | 5 字节(略优,但不足以抵消上述成本) |
代价:跨时区展示需自行换算——本项目全链路 Asia/Shanghai,不成为问题。
### 5.2 为什么不冗余 `doneCount` / `streak`
- **并发**:计数需事务 + 行锁,聚合查询天然一致。
- **改配置**:用户把「每周 3 次」改成「每周 5 次」,冗余计数立刻失真,需要全量重算;现方案改 `target` 立即生效。
- **撤销**:撤销即删一条记录,无需维护计数器。
- **成本**:单个用户打卡项数量在 10~30 量级,月历查询一次拉取整月记录(≤ 数百行)在内存聚合,开销可接受。
### 5.3 `seq` 与幂等
同日多次打卡用 `seq = max(seq)+1`。唯一约束 `(habitId, date, seq)` 使并发重复提交必然撞 P2002,服务端转 409「该次打卡已记录」,前端幂等收敛,不会出现「点两下加两次」的体验事故。
### 5.4 归档 vs 物理删除
| 操作 | 语义 | 数据影响 |
| --- | --- | --- |
| `DELETE /api/checkin/habits/:id` | 归档(默认) | `archived=true`,记录全保留 |
| `DELETE /api/checkin/habits/:id?hard=1` | 物理删除 | 先删 CheckIn,再删 Habit |
| `DELETE /api/checklists/:id` | 物理删除(一次性清单,不留历史) | 先删 Item,再删 List |
理由:打卡是长期行为数据,历史构成 streak 与完成率;清单是短期事务,用完即归档/删除。
### 5.5 模板用 JSON 而非明细表
模板条目结构固定(`title` + `note`)、只在「创建清单」时整体读取并展开、不需要按条目检索或统计 —— 用 `JSON` 列避免额外的 `TemplateItem` 表与 join。展开后落到 `CheckListItem`,之后模板改动不影响已生成清单。
---
## 6. 数据量估算
单用户规模假设:打卡项 20 个,平均每天打卡 25 次;清单每月 4 份,每份 12 条。
| 表 | 年增量(单用户) | 行大小(约) | 年占用(约) |
| --- | --- | --- | --- |
| `CheckIn` | 25 × 365 ≈ 9,100 | ~80 B | ~0.7 MB |
| `CheckListItem` | 4 × 12 × 12 = 576 | ~90 B | ~0.05 MB |
| `Habit` / `CheckList` / `CheckListTemplate` | < 100 | ~200 B | 忽略 |
结论:**无需分区、无需归档策略**。10 用户 × 5 年 ≈ 45 万行 CheckIn,仍远低于单表瓶颈。若未来接入多租户或做年视图长周期统计,再考虑按月汇总表。
---
## 7. 典型查询
### 7.1 今日待办 + 周期进度
```sql
-- 步骤 1: 取未归档打卡项 (走 Habit_userId_archived_sortOrder_idx)
SELECT id, name, period, target, color FROM Habit
WHERE userId = ? AND archived = 0 ORDER BY sortOrder, id;
-- 步骤 2: 取覆盖所有周期窗口的最小区间内的记录 (走 CheckIn_userId_date_idx)
SELECT habitId, date FROM CheckIn
WHERE userId = ? AND date >= ? AND date <= ?; -- 如 week: '2026-10-19' ~ '2026-10-25'
-- 步骤 3: 内存按 habitId/date 分组 -> 计算 done/percent/streak
```
### 7.2 打一次卡
```sql
SELECT MAX(seq) FROM CheckIn WHERE habitId = ? AND date = ?; -- -> seq
INSERT INTO CheckIn (habitId, userId, date, seq, note, source) VALUES (?, ?, ?, ?, ?, 'user');
-- 冲突 -> P2002 -> 409
```
撤销(撤销本周期最近一次):
```sql
SELECT id FROM CheckIn WHERE habitId = ? AND date BETWEEN ? AND ?
ORDER BY date DESC, seq DESC LIMIT 1;
DELETE FROM CheckIn WHERE id = ?;
```
### 7.3 月历(日历视图数据来源)
```sql
SELECT habitId, date FROM CheckIn
WHERE userId = ? AND date >= '2026-10-01' AND date <= '2026-10-31';
```
按天聚合后与 `Habit.expectedPerDay` 比较得出日状态:
| 状态 | 条件 | 展示 |
| --- | --- | --- |
| `full` | 全部打卡项当日次数 ≥ 日均期望 | 绿点 |
| `over` | 有任一打卡项当日次数 > 日均期望 | 紫点 |
| `partial` | 有完成但未全完成 | 黄点 |
| `empty` | 无记录或当天无打卡项 | 无点 |
`Habit.createdAt` 早于该日的才计入期望,避免新建打卡项把历史日期标成「未完成」。
### 7.4 清单列表(带进度)
```sql
SELECT l.*, i.* FROM CheckList l
LEFT JOIN CheckListItem i ON i.listId = l.id
WHERE l.userId = ? AND l.archived = ?
ORDER BY l.updatedAt DESC, i.sortOrder, i.id;
```
---
## 8. 迁移
| 项 | 内容 |
| --- | --- |
| 迁移目录 | `prisma/migrations/20261022120000_add_checkin_checklist/` |
| 内容 | 5 张 `CREATE TABLE` + 6 条 `ADD CONSTRAINT`(见 `migration.sql`) |
| 生成方式 | 手写 SQL(与 `prisma migrate diff` 产出格式一致),已通过 `prisma migrate deploy` 验证 |
| 部署命令 | `npx prisma migrate deploy && npx prisma generate` |
| 回滚 | 无自动回滚。应急:`DROP TABLE CheckIn, CheckListItem, CheckList, CheckListTemplate, Habit;`(注意外键顺序)并把 `_prisma_migrations` 中该记录删除 |
>V0.2 全为新增表与新增字段(`User` 侧仅新增 Prisma 反向关系,不产生 DDL),对既有数据零影响。
---
## 9. V0.3 预留
| 方向 | 可能的表变更 |
| --- | --- |
| 打卡提醒 | `Habit` 增加 `remindAt TIME`、`remindDays` |
| 打卡统计快照 | 新增 `HabitDailyStat(userId, date, done, expected)` 汇总表,用于年视图/热力图加速 |
| 清单共享协作 | 新增 `CheckListShare(listId, userId, role)` |
| 模板市场 | `CheckListTemplate` 增加 `visibility`、`useCount` |
以上均可通过「新增表 / 新增可空字段」完成,不影响 V0.2 数据结构。
+889
View File
@@ -0,0 +1,889 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>iBoard V0.2 界面设计 · 打卡 / 日历 / 清单</title>
<style>
:root{
--bg:#f0f2f5; --page:#ffffff; --card:#ffffff;
--text:#333333; --muted:#666666; --weak:#999999; --line:#eeeeee;
--primary:#1677ff; --success:#00b578; --warn:#ffc53d; --over:#a855f7;
--danger:#ff3141; --orange:#ff8f1f; --teal:#14b8a6; --pink:#f43f9e;
--nav-h:45px; --tab-h:72px;
}
*{box-sizing:border-box;margin:0;padding:0}
body{
background:var(--bg); color:#1f2328; font-size:14px; line-height:1.6;
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI','PingFang SC','Hiragino Sans GB','Microsoft YaHei',Roboto,sans-serif;
-webkit-font-smoothing:antialiased;
}
.wrap{max-width:1400px;margin:0 auto;padding:36px 24px 72px}
.doc-head{border-bottom:1px solid #e3e6ea;padding-bottom:20px;margin-bottom:28px}
.doc-head h1{font-size:26px;font-weight:700;letter-spacing:.5px}
.doc-head p{color:var(--muted);font-size:13px;margin-top:6px}
.meta{display:flex;flex-wrap:wrap;gap:8px;margin-top:14px}
.meta span{font-size:12px;background:#fff;border:1px solid #e3e6ea;border-radius:999px;padding:3px 12px;color:var(--muted)}
h2{font-size:19px;font-weight:700;margin:40px 0 6px;display:flex;align-items:center;gap:10px}
h2::before{content:"";width:4px;height:17px;background:var(--primary);border-radius:2px}
h2 .sub{font-size:12px;font-weight:400;color:var(--weak)}
h3{font-size:15px;font-weight:700;margin:22px 0 10px;color:#24292f}
p.desc{color:var(--muted);font-size:13px;margin-bottom:16px}
/* ---------- 设计规范 ---------- */
.tokens{display:grid;grid-template-columns:repeat(auto-fit,minmax(300px,1fr));gap:18px}
.panel{background:#fff;border:1px solid #e6e8eb;border-radius:12px;padding:16px 18px}
.panel h4{font-size:13px;color:var(--muted);font-weight:600;margin-bottom:12px;letter-spacing:.4px}
.swatches{display:flex;flex-wrap:wrap;gap:10px}
.sw{width:88px;text-align:center}
.sw i{display:block;height:44px;border-radius:8px;border:1px solid rgba(0,0,0,.06)}
.sw b{display:block;font-size:11px;font-weight:600;margin-top:5px}
.sw em{display:block;font-size:10px;font-style:normal;color:var(--weak)}
.kv{display:grid;grid-template-columns:96px 1fr;gap:6px 12px;font-size:12px}
.kv span{color:var(--weak)}
.kv b{font-weight:600}
.type-row{display:flex;align-items:baseline;gap:12px;padding:6px 0;border-bottom:1px dashed var(--line);font-size:12px}
.type-row:last-child{border-bottom:none}
.type-row em{font-style:normal;color:var(--weak);width:132px;flex-shrink:0}
.type-row span{color:#444}
/* ---------- 手机 ---------- */
.stage{display:grid;grid-template-columns:repeat(auto-fit,minmax(420px,1fr));gap:30px 26px;justify-items:center}
.shot{width:420px}
.phone{
width:420px;height:820px;background:var(--page);position:relative;overflow:hidden;
border:1px solid #d8dde3;border-radius:22px;box-shadow:0 10px 24px rgba(15,23,42,.08);
display:flex;flex-direction:column;
}
.phone::after{content:"";position:absolute;top:8px;left:50%;transform:translateX(-50%);width:118px;height:20px;background:#111827;border-radius:12px;opacity:.9}
.navbar{height:var(--nav-h);margin-top:28px;flex-shrink:0;display:flex;align-items:center;justify-content:center;
font-size:17px;font-weight:600;border-bottom:1px solid var(--line);background:#fff}
.body{flex:1;min-height:0;overflow:hidden;background:var(--bg);padding:0 0 8px}
.tabbar{height:var(--tab-h);flex-shrink:0;background:#fff;border-top:1px solid var(--line);
display:flex;align-items:flex-start;padding-top:7px}
.tab{flex:1;display:flex;flex-direction:column;align-items:center;gap:3px;color:var(--weak);font-size:11px}
.tab.on{color:var(--primary)}
.tab svg{width:23px;height:23px}
.caption{margin-top:10px;background:#fff;border:1px solid #e6e8eb;border-radius:10px;padding:10px 14px}
.caption b{font-size:13px}
.caption p{font-size:12px;color:var(--muted);margin-top:3px}
.tag{display:inline-block;font-size:11px;color:var(--primary);background:#f0f5ff;border-radius:4px;padding:1px 6px;margin-left:6px}
/* ---------- 打卡视图 ---------- */
.habit-head{display:flex;justify-content:space-between;align-items:flex-end;padding:12px 14px 8px}
.habit-date{font-size:18px;font-weight:700}
.habit-summary{font-size:12px;color:var(--muted);margin-top:2px}
.habit-summary strong{color:var(--primary);font-size:14px}
.badge{font-size:11px;color:#ad6800;background:#fff7e6;border-radius:4px;padding:1px 5px;margin-left:6px}
.mini-btn{font-size:11px;border:1px solid var(--primary);color:var(--primary);border-radius:999px;padding:3px 10px;background:#fff}
.mini-btn.solid{background:var(--primary);color:#fff;border-color:var(--primary)}
.mini-btn.grey{border-color:#dcdcdc;color:var(--muted)}
.head-right{display:flex;gap:6px}
.habit-list{padding:0 12px;display:flex;flex-direction:column;gap:10px}
.habit-card{position:relative;background:#fff;border-radius:10px;padding:12px;box-shadow:0 1px 2px rgba(0,0,0,.04);
display:grid;grid-template-columns:auto 1fr auto;grid-template-areas:"ring main actions" "foot foot foot";gap:8px 12px}
.habit-card.done{background:#f7fbf8}
.ring{grid-area:ring;width:58px;height:58px;position:relative;display:flex;align-items:center;justify-content:center}
.ring svg{display:block}
.ring-center{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;font-size:16px;font-weight:700}
.ring-center em{font-style:normal;font-size:10px;color:var(--muted);font-weight:400}
.ring-over{position:absolute;top:-4px;right:-10px;font-size:10px;color:#fff;background:var(--orange);border-radius:999px;padding:0 5px}
.habit-main{grid-area:main;min-width:0}
.habit-name{font-size:15px;font-weight:600}
.habit-meta{font-size:12px;color:var(--muted);margin-top:3px}
.streak{margin-left:8px;color:var(--orange);background:rgba(255,143,31,.12);border-radius:4px;padding:1px 6px}
.habit-range{font-size:11px;color:var(--muted);margin-top:3px}
.over-txt{margin-left:8px;color:var(--orange)}
.habit-actions{grid-area:actions;display:flex;align-items:center}
.habit-foot{grid-area:foot;display:flex;gap:14px;padding-top:8px;border-top:1px dashed var(--line)}
.link{font-size:12px;color:var(--primary)}
.link.danger{color:var(--danger)}
/* ---------- 日历视图 ---------- */
.cal-month-bar{display:flex;align-items:center;gap:10px;padding:12px 14px 6px}
.cal-month{font-size:16px;font-weight:700}
.cal-arrow{font-size:20px;color:var(--muted);padding:0 4px;line-height:1}
.cal-today{margin-left:auto;font-size:12px;color:var(--primary)}
.seg{display:flex;margin:4px 14px 10px;background:#f2f3f5;border-radius:8px;padding:3px}
.seg div{flex:1;text-align:center;font-size:13px;padding:5px 0;border-radius:6px;color:var(--muted)}
.seg div.on{background:#fff;color:var(--text);font-weight:600;box-shadow:0 1px 2px rgba(0,0,0,.06)}
.cal-grid{margin:0 12px;background:#fff;border-radius:10px;padding:8px 6px 10px;box-shadow:0 1px 2px rgba(0,0,0,.04)}
.grid7{display:grid;grid-template-columns:repeat(7,1fr)}
.wd span{text-align:center;font-size:11px;color:var(--muted);padding:4px 0}
.day{display:flex;flex-direction:column;align-items:center;justify-content:flex-start;padding:5px 0;border-radius:8px}
.day .num{font-size:13px}
.day.out .num{color:#c8c8c8}
.day.future .num{color:#b0b0b0}
.day.today .num{color:var(--primary);font-weight:700}
.day.sel{background:var(--primary)}
.day.sel .num{color:#fff;font-weight:600}
.dot{width:5px;height:5px;border-radius:50%;margin-top:3px}
.dot.partial{background:var(--warn)} .dot.full{background:var(--success)} .dot.over{background:var(--over)}
.day.sel .dot{background:#fff}
.cal-rate{font-size:12px;color:var(--muted);padding:8px 14px}
.cal-rate strong{color:var(--primary)}
.cal-detail-title{font-size:14px;font-weight:600;padding:6px 14px 8px}
.cal-item{display:flex;align-items:center;gap:8px;background:#fff;border-radius:8px;padding:10px 12px;margin:0 12px 8px;box-shadow:0 1px 2px rgba(0,0,0,.04)}
.cal-item.done{background:#f7fbf8}
.cdot{width:8px;height:8px;border-radius:50%;flex-shrink:0}
.cname{font-size:14px;font-weight:600}
.cmeta{font-size:11px;color:var(--muted);flex:1;text-align:right}
.cplan{font-size:12px;color:var(--muted)}
/* ---------- 清单视图 ---------- */
.list-head{display:flex;align-items:center;gap:8px;padding:12px 14px 10px}
.list-head .seg{flex:1;margin:0}
.checklist-card{background:#fff;border-radius:10px;padding:12px;margin:0 12px 10px;box-shadow:0 1px 2px rgba(0,0,0,.04)}
.checklist-card.done{background:#f7fbf8}
.cl-title-row{display:flex;align-items:center;gap:8px}
.cl-title{font-size:15px;font-weight:600}
.cl-scene{font-size:11px;color:var(--primary);background:#f0f5ff;border-radius:4px;padding:1px 6px}
.progress-row{display:flex;align-items:center;gap:8px;margin-top:8px}
.bar{flex:1;height:6px;border-radius:3px;background:#f0f0f0;overflow:hidden}
.bar i{display:block;height:100%;border-radius:3px;background:var(--success)}
.count{font-size:12px;color:var(--muted)}
.arrow{color:var(--muted);transform:rotate(90deg)}
.cl-body{margin-top:10px;padding-top:8px;border-top:1px dashed var(--line)}
.cl-item{display:flex;align-items:center;gap:8px;padding:7px 0}
.box{width:20px;height:20px;border-radius:50%;border:1.5px solid #d0d0d0;display:flex;align-items:center;justify-content:center;flex-shrink:0}
.cl-item.done .box{background:var(--success);border-color:var(--success)}
.box svg{opacity:0} .cl-item.done .box svg{opacity:1}
.cl-item-title{flex:1;font-size:14px}
.cl-item.done .cl-item-title{color:#a0a0a0;text-decoration:line-through}
.cl-del{color:#ccc;font-size:18px;line-height:1;padding:0 4px}
.cl-add{display:flex;gap:8px;margin-top:8px}
.cl-add .inp{flex:1;border:1px solid var(--line);border-radius:6px;padding:6px 8px;font-size:13px;color:var(--weak);background:#fff}
.cl-actions{display:flex;flex-wrap:wrap;gap:12px;margin-top:10px}
/* ---------- 弹窗 ---------- */
.mask{position:absolute;inset:28px 0 0 0;background:rgba(0,0,0,.45);z-index:9}
.sheet{position:absolute;left:0;right:0;bottom:0;background:#fff;border-radius:12px 12px 0 0;padding:16px 16px 12px;z-index:10;max-height:560px;overflow:hidden}
.sheet-title{font-size:16px;font-weight:700;margin-bottom:12px}
.field{margin-bottom:13px}
.field label{display:block;font-size:12px;color:var(--muted);margin-bottom:6px}
.inp{border:1px solid var(--line);border-radius:8px;padding:8px 10px;font-size:14px;background:#fff;color:var(--text)}
.inp.ph{color:#b8bcc4}
.chips{display:flex;gap:8px}
.chip{flex:1;text-align:center;font-size:13px;padding:6px 0;border-radius:8px;background:#f5f6f8;color:var(--muted)}
.chip.on{background:var(--primary);color:#fff;font-weight:600}
.stepper{display:inline-flex;align-items:center;gap:10px}
.stepper span{width:28px;height:28px;border-radius:6px;background:#f5f6f8;display:flex;align-items:center;justify-content:center;color:var(--muted)}
.stepper span.act{background:var(--primary);color:#fff}
.stepper b{font-size:16px;min-width:24px;text-align:center}
.colors{display:flex;gap:10px;flex-wrap:wrap}
.cd{width:26px;height:26px;border-radius:50%;border:2px solid transparent}
.cd.on{border-color:rgba(0,0,0,.25);transform:scale(1.12)}
.btn-primary{display:block;text-align:center;background:var(--primary);color:#fff;border-radius:8px;padding:10px 0;font-size:15px;font-weight:600;margin-top:18px}
.btn-ghost{display:block;text-align:center;border:1px solid var(--line);border-radius:8px;padding:9px 0;font-size:15px;color:var(--muted);margin-top:8px;background:#fff}
/* ---------- 特效 ---------- */
.fx{position:absolute;inset:0;pointer-events:none}
.fx .halo{position:absolute;left:50%;top:50%;width:88px;height:88px;margin:-44px 0 0 -44px;border-radius:50%;border:2px solid var(--success);opacity:.55}
.fx .halo2{width:108px;height:108px;margin:-54px 0 0 -54px;opacity:.25}
.fx i{position:absolute;left:50%;top:50%;width:6px;height:6px;border-radius:50%;}
/* ---------- 空态 ---------- */
.empty{text-align:center;padding:70px 20px;color:var(--weak)}
.empty .ico{width:64px;height:64px;margin:0 auto 12px;border-radius:50%;background:#f2f3f5;display:flex;align-items:center;justify-content:center;color:#c4c8ce;font-size:30px}
.empty p{font-size:13px;margin-bottom:12px}
/* ---------- 表格 ---------- */
table{width:100%;border-collapse:collapse;background:#fff;border:1px solid #e6e8eb;border-radius:10px;overflow:hidden;font-size:13px}
th,td{border-bottom:1px solid var(--line);padding:9px 12px;text-align:left;vertical-align:middle}
th{background:#fafbfc;font-weight:600;color:#444;white-space:nowrap}
tr:last-child td{border-bottom:none}
td code{background:#f4f6f8;border-radius:4px;padding:1px 5px;font-size:12px}
.legend{display:flex;flex-wrap:wrap;gap:18px;font-size:12px;color:var(--muted);margin:10px 0 0}
.legend span{display:flex;align-items:center;gap:6px}
.note{font-size:12px;color:var(--weak);margin-top:8px}
.flow{background:#fff;border:1px solid #e6e8eb;border-radius:12px;padding:16px 18px;font-size:13px;color:#333;white-space:pre;overflow-x:auto;line-height:1.7}
</style>
</head>
<body>
<div class="wrap">
<div class="doc-head">
<h1>iBoard V0.2 · 界面设计</h1>
<p>打卡 / 日历 / 清单 —— 移动端静态高保真原型(设计基线 420 × 820,适配 375~430px)</p>
<div class="meta">
<span>版本 V0.2</span><span>分支 dev-0.2</span><span>antd-mobile 风格</span>
<span>底部 5 Tab(新增「打卡」)</span><span>时区 Asia/Shanghai</span><span>文档日期 2026-10-22</span>
</div>
</div>
<!-- ============ 1 设计规范 ============ -->
<h2>1 设计规范<span class="sub">Design Tokens</span></h2>
<p class="desc">沿用项目既有 <code>:root</code> 变量(<code>src/styles/globals.css</code>),本模块不引入新主题。</p>
<div class="tokens">
<div class="panel">
<h4>语义色</h4>
<div class="swatches">
<div class="sw"><i style="background:#1677ff"></i><b>#1677ff</b><em>主色 / 今日 / 链接</em></div>
<div class="sw"><i style="background:#00b578"></i><b>#00b578</b><em>完成 / 进度条</em></div>
<div class="sw"><i style="background:#ffc53d"></i><b>#ffc53d</b><em>部分完成 / 粒子</em></div>
<div class="sw"><i style="background:#a855f7"></i><b>#a855f7</b><em>超额完成</em></div>
<div class="sw"><i style="background:#ff8f1f"></i><b>#ff8f1f</b><em>连续天数 / 超额徽标</em></div>
<div class="sw"><i style="background:#ff3141"></i><b>#ff3141</b><em>危险操作</em></div>
<div class="sw"><i style="background:#f5f5f5"></i><b>#f5f5f5</b><em>页面底色</em></div>
<div class="sw"><i style="background:#f7fbf8"></i><b>#f7fbf8</b><em>已完成卡片底</em></div>
</div>
<div class="legend" style="margin-top:14px">
<span><i class="dot full"></i>日历·全部完成</span>
<span><i class="dot partial"></i>日历·部分完成</span>
<span><i class="dot over"></i>日历·超额</span>
</div>
</div>
<div class="panel">
<h4>布局与圆角</h4>
<div class="kv">
<span>视口</span><b>420 × 820(375~430 自适应)</b>
<span>NavBar 高</span><b>45px</b>
<span>TabBar 高</span><b>72px(含安全区)</b>
<span>页面内边距</span><b>左右 12px</b>
<span>卡片圆角</span><b>10px</b>
<span>卡片间距</span><b>10px 纵向</b>
<span>卡片阴影</span><b>0 1px 2px rgba(0,0,0,.04)</b>
<span>环形尺寸</span><b>58 × 58,环宽 6px</b>
<span>最小点按区</span><b>≥ 40 × 40</b>
</div>
</div>
<div class="panel">
<h4>字号层级</h4>
<div class="type-row"><em>18 / 700</em><span style="font-size:18px;font-weight:700">日期标题「10月22日」</span></div>
<div class="type-row"><em>17 / 600</em><span style="font-size:17px;font-weight:600">NavBar 标题「打卡」</span></div>
<div class="type-row"><em>16 / 700</em><span style="font-size:16px;font-weight:700">环形中心数字 / 弹窗标题</span></div>
<div class="type-row"><em>15 / 600</em><span style="font-size:15px;font-weight:600">打卡项名 / 清单标题</span></div>
<div class="type-row"><em>13 / 400</em><span style="font-size:13px">正文 / 日历日期数字</span></div>
<div class="type-row"><em>12 / 400</em><span style="font-size:12px;color:#666">次要信息 / 操作链接</span></div>
<div class="type-row"><em>11 / 400</em><span style="font-size:11px;color:#999">备注 / 周期范围 / 徽标</span></div>
</div>
</div>
<!-- ============ 2 主界面 ============ -->
<h2>2 主界面<span class="sub">三个子视图(SwipeTabs 左右滑动切换)</span></h2>
<div class="stage">
<!-- ---------- 2.1 打卡视图 ---------- -->
<div class="shot">
<div class="phone">
<div class="navbar">打卡</div>
<div class="body">
<div class="habit-head">
<div>
<div class="habit-date">10月22日 周四</div>
<div class="habit-summary">已完成 <strong>2</strong> / 4</div>
</div>
<div class="head-right">
<div class="mini-btn">特效开</div>
<div class="mini-btn solid">+ 新建</div>
</div>
</div>
<div class="habit-list">
<!-- 未完成:每天 2 次,0/2 -->
<div class="habit-card">
<div class="ring">
<svg width="58" height="58" viewBox="0 0 58 58">
<circle cx="29" cy="29" r="26" fill="none" stroke="#ececf0" stroke-width="6"/>
<circle cx="29" cy="29" r="26" fill="none" stroke="#1677ff" stroke-width="6" stroke-linecap="round"
stroke-dasharray="163.4" stroke-dashoffset="163.4" transform="rotate(-90 29 29)"/>
</svg>
<div class="ring-center">0<em>/2</em></div>
</div>
<div class="habit-main">
<div class="habit-name">喝水</div>
<div class="habit-meta">每天 2 次<span class="streak">连续 12</span></div>
<div class="habit-range">今日</div>
</div>
<div class="habit-actions"><div class="mini-btn solid">打卡</div></div>
<div class="habit-foot"><span class="link">编辑</span><span class="link danger">归档</span></div>
</div>
<!-- 未完成:每周 3 次,1/3 = 33.3% -->
<div class="habit-card">
<div class="ring">
<svg width="58" height="58" viewBox="0 0 58 58">
<circle cx="29" cy="29" r="26" fill="none" stroke="#ececf0" stroke-width="6"/>
<circle cx="29" cy="29" r="26" fill="none" stroke="#ff8f1f" stroke-width="6" stroke-linecap="round"
stroke-dasharray="163.4" stroke-dashoffset="108.9" transform="rotate(-90 29 29)"/>
</svg>
<div class="ring-center">1<em>/3</em></div>
</div>
<div class="habit-main">
<div class="habit-name">健身</div>
<div class="habit-meta">每周 3 次<span class="streak">连续 5</span></div>
<div class="habit-range">10-19 ~ 10-25</div>
</div>
<div class="habit-actions"><div class="mini-btn solid">打卡</div></div>
<div class="habit-foot"><span class="link">撤销</span><span class="link">编辑</span><span class="link danger">归档</span></div>
</div>
<!-- 已完成:每月 12 次,12/12 闭合 -->
<div class="habit-card done">
<div class="ring">
<svg width="58" height="58" viewBox="0 0 58 58">
<circle cx="29" cy="29" r="26" fill="none" stroke="#ececf0" stroke-width="6"/>
<circle cx="29" cy="29" r="26" fill="none" stroke="#00b578" stroke-width="6" stroke-linecap="round"
stroke-dasharray="163.4" stroke-dashoffset="0" transform="rotate(-90 29 29)"/>
</svg>
<div class="ring-center">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
<path d="M4 12.5l5 5L20 6.5" stroke="#00b578" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</div>
</div>
<div class="habit-main">
<div class="habit-name">读书</div>
<div class="habit-meta">每月 12 次<span class="streak">连续 3</span></div>
<div class="habit-range">10-01 ~ 10-31</div>
</div>
<div class="habit-actions"><div class="mini-btn grey">再打一次</div></div>
<div class="habit-foot"><span class="link">撤销</span><span class="link">编辑</span><span class="link danger">归档</span></div>
</div>
<!-- 已完成 + 超额:每天 1 次,打了 2 次 -->
<div class="habit-card done">
<div class="ring">
<svg width="58" height="58" viewBox="0 0 58 58">
<circle cx="29" cy="29" r="26" fill="none" stroke="#ececf0" stroke-width="6"/>
<circle cx="29" cy="29" r="26" fill="none" stroke="#a855f7" stroke-width="6" stroke-linecap="round"
stroke-dasharray="163.4" stroke-dashoffset="0" transform="rotate(-90 29 29)"/>
</svg>
<div class="ring-center">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
<path d="M4 12.5l5 5L20 6.5" stroke="#a855f7" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</div>
<span class="ring-over">+1</span>
</div>
<div class="habit-main">
<div class="habit-name">吃药</div>
<div class="habit-meta">每天 1 次<span class="streak">连续 30</span></div>
<div class="habit-range">今日<span class="over-txt">超额 1</span></div>
</div>
<div class="habit-actions"><div class="mini-btn grey">再打一次</div></div>
<div class="habit-foot"><span class="link">撤销</span><span class="link">编辑</span><span class="link danger">归档</span></div>
</div>
</div>
</div>
<div class="tabbar">
<div class="tab">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M4 11l8-6 8 6v8a1 1 0 0 1-1 1h-4v-6H9v6H5a1 1 0 0 1-1-1z"/></svg>首页</div>
<div class="tab">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M4 19V9m5 10V5m5 14v-7m5 7V8"/></svg>宏观</div>
<div class="tab">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><rect x="3" y="4" width="18" height="7" rx="2"/><rect x="3" y="13" width="18" height="7" rx="2"/><circle cx="7" cy="7.5" r="1"/><circle cx="7" cy="16.5" r="1"/></svg>监控</div>
<div class="tab on">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><circle cx="12" cy="12" r="8.5"/><path d="M8 12.4l2.6 2.6L16.5 9.5" stroke-linecap="round" stroke-linejoin="round"/></svg>打卡</div>
<div class="tab">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><circle cx="12" cy="8" r="3.6"/><path d="M4.5 20c1.4-3.6 4.1-5.4 7.5-5.4S18.1 16.4 19.5 20"/></svg>我</div>
</div>
</div>
<div class="caption">
<b>2.1 打卡视图 · 今天<span class="tag">默认进入</span></b>
<p>头部日期 + 完成数;未完成卡片排在前面。环形中心未达标显示 <code>done/target</code>,达标显示对勾(每天 1 次打完即刻闭合);超额在右上角显示 <code>+n</code>。点环或「打卡」按钮均可打卡,卡片底部提供撤销 / 编辑 / 归档。</p>
</div>
</div>
<!-- ---------- 2.2 日历视图 ---------- -->
<div class="shot">
<div class="phone">
<div class="navbar">打卡</div>
<div class="body">
<div class="seg" style="width:calc(100% - 28px);margin-left:14px">
<div>打卡</div><div class="on">日历</div><div>清单</div>
</div>
<div class="cal-month-bar">
<span class="cal-arrow">‹</span><span class="cal-month">2026年10月</span><span class="cal-arrow">›</span>
<span class="cal-today">回到今天</span>
</div>
<div class="seg">
<div>昨天</div><div class="on">今天</div><div>明天</div>
</div>
<div class="cal-grid">
<div class="grid7 wd"><span>一</span><span>二</span><span>三</span><span>四</span><span>五</span><span>六</span><span>日</span></div>
<div class="grid7">
<div class="day out"><span class="num">28</span></div>
<div class="day out"><span class="num">29</span></div>
<div class="day out"><span class="num">30</span></div>
<div class="day"><span class="num">1</span><i class="dot full"></i></div>
<div class="day"><span class="num">2</span><i class="dot full"></i></div>
<div class="day"><span class="num">3</span><i class="dot partial"></i></div>
<div class="day"><span class="num">4</span><i class="dot full"></i></div>
<div class="day"><span class="num">5</span></div>
<div class="day"><span class="num">6</span><i class="dot full"></i></div>
<div class="day"><span class="num">7</span><i class="dot over"></i></div>
<div class="day"><span class="num">8</span><i class="dot full"></i></div>
<div class="day"><span class="num">9</span><i class="dot partial"></i></div>
<div class="day"><span class="num">10</span><i class="dot full"></i></div>
<div class="day"><span class="num">11</span><i class="dot full"></i></div>
<div class="day"><span class="num">12</span></div>
<div class="day"><span class="num">13</span></div>
<div class="day"><span class="num">14</span><i class="dot full"></i></div>
<div class="day"><span class="num">15</span><i class="dot full"></i></div>
<div class="day"><span class="num">16</span><i class="dot partial"></i></div>
<div class="day"><span class="num">17</span><i class="dot full"></i></div>
<div class="day"><span class="num">18</span><i class="dot full"></i></div>
<div class="day"><span class="num">19</span><i class="dot full"></i></div>
<div class="day"><span class="num">20</span><i class="dot full"></i></div>
<div class="day"><span class="num">21</span><i class="dot full"></i></div>
<div class="day today sel"><span class="num">22</span><i class="dot partial"></i></div>
<div class="day future"><span class="num">23</span></div>
<div class="day future"><span class="num">24</span></div>
<div class="day future"><span class="num">25</span></div>
<div class="day future"><span class="num">26</span></div>
<div class="day future"><span class="num">27</span></div>
<div class="day future"><span class="num">28</span></div>
<div class="day future"><span class="num">29</span></div>
<div class="day future"><span class="num">30</span></div>
<div class="day future"><span class="num">31</span></div>
<div class="day out"><span class="num">1</span></div>
</div>
</div>
<div class="cal-rate">本月完成率 <strong>82%</strong></div>
<div class="cal-detail-title">今天 · 2026-10-22</div>
<div class="cal-item done">
<i class="cdot" style="background:#1677ff"></i><span class="cname">喝水</span>
<span class="cmeta">每天 2 次 · 当日 2/2</span>
<span class="mini-btn grey">已达标</span>
</div>
<div class="cal-item">
<i class="cdot" style="background:#ff8f1f"></i><span class="cname">健身</span>
<span class="cmeta">每周 3 次 · 当日 0/1</span>
<span class="mini-btn solid">打卡</span>
</div>
<div class="cal-item done">
<i class="cdot" style="background:#00b578"></i><span class="cname">读书</span>
<span class="cmeta">每月 12 次 · 当日 1/1</span>
<span class="mini-btn grey">已达标</span>
</div>
<div class="cal-item done">
<i class="cdot" style="background:#a855f7"></i><span class="cname">吃药</span>
<span class="cmeta">每天 1 次 · 当日 2/1</span>
<span class="mini-btn grey">已达标</span>
</div>
</div>
<div class="tabbar">
<div class="tab"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M4 11l8-6 8 6v8a1 1 0 0 1-1 1h-4v-6H9v6H5a1 1 0 0 1-1-1z"/></svg>首页</div>
<div class="tab"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M4 19V9m5 10V5m5 14v-7m5 7V8"/></svg>宏观</div>
<div class="tab"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><rect x="3" y="4" width="18" height="7" rx="2"/><rect x="3" y="13" width="18" height="7" rx="2"/><circle cx="7" cy="7.5" r="1"/><circle cx="7" cy="16.5" r="1"/></svg>监控</div>
<div class="tab on"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><circle cx="12" cy="12" r="8.5"/><path d="M8 12.4l2.6 2.6L16.5 9.5" stroke-linecap="round" stroke-linejoin="round"/></svg>打卡</div>
<div class="tab"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><circle cx="12" cy="8" r="3.6"/><path d="M4.5 20c1.4-3.6 4.1-5.4 7.5-5.4S18.1 16.4 19.5 20"/></svg>我</div>
</div>
</div>
<div class="caption">
<b>2.2 日历视图 · 今天选中</b>
<p>周一起始月历 + 「昨天 / 今天 / 明天」快捷分段;日期下方状态点:绿=全部完成、黄=部分完成、紫=超额、无点=未完成或未来。今天用主色数字并选中高亮,未来日期数字弱化。下方为该日明细,可直接打卡;底部展示本月完成率。</p>
</div>
</div>
<!-- ---------- 2.3 清单视图 ---------- -->
<div class="shot">
<div class="phone">
<div class="navbar">打卡</div>
<div class="body">
<div class="seg" style="width:calc(100% - 28px);margin-left:14px">
<div>打卡</div><div>日历</div><div class="on">清单</div>
</div>
<div class="list-head">
<div class="seg"><div class="on">进行中</div><div>已归档</div></div>
<div class="mini-btn solid">+ 新建</div>
</div>
<div class="checklist-card">
<div class="cl-title-row"><span class="cl-title">出差 · 杭州</span><span class="cl-scene">出差</span></div>
<div class="progress-row">
<div class="bar"><i style="width:66.7%"></i></div><span class="count">8/12</span><span class="arrow">›</span>
</div>
<div class="cl-body">
<div class="cl-item done"><span class="box"><svg viewBox="0 0 24 24" width="14" height="14" fill="none"><path d="M4 12.5l5 5L20 6.5" stroke="#fff" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/></svg></span><span class="cl-item-title">身份证 / 证件</span><span class="cl-del">×</span></div>
<div class="cl-item done"><span class="box"><svg viewBox="0 0 24 24" width="14" height="14" fill="none"><path d="M4 12.5l5 5L20 6.5" stroke="#fff" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/></svg></span><span class="cl-item-title">手机充电器 + 充电宝</span><span class="cl-del">×</span></div>
<div class="cl-item done"><span class="box"><svg viewBox="0 0 24 24" width="14" height="14" fill="none"><path d="M4 12.5l5 5L20 6.5" stroke="#fff" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/></svg></span><span class="cl-item-title">笔记本电脑 + 电源</span><span class="cl-del">×</span></div>
<div class="cl-item done"><span class="box"><svg viewBox="0 0 24 24" width="14" height="14" fill="none"><path d="M4 12.5l5 5L20 6.5" stroke="#fff" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/></svg></span><span class="cl-item-title">耳机</span><span class="cl-del">×</span></div>
<div class="cl-item done"><span class="box"><svg viewBox="0 0 24 24" width="14" height="14" fill="none"><path d="M4 12.5l5 5L20 6.5" stroke="#fff" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/></svg></span><span class="cl-item-title">换洗衣物</span><span class="cl-del">×</span></div>
<div class="cl-item done"><span class="box"><svg viewBox="0 0 24 24" width="14" height="14" fill="none"><path d="M4 12.5l5 5L20 6.5" stroke="#fff" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/></svg></span><span class="cl-item-title">洗漱包</span><span class="cl-del">×</span></div>
<div class="cl-item done"><span class="box"><svg viewBox="0 0 24 24" width="14" height="14" fill="none"><path d="M4 12.5l5 5L20 6.5" stroke="#fff" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/></svg></span><span class="cl-item-title">常用药</span><span class="cl-del">×</span></div>
<div class="cl-item done"><span class="box"><svg viewBox="0 0 24 24" width="14" height="14" fill="none"><path d="M4 12.5l5 5L20 6.5" stroke="#fff" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/></svg></span><span class="cl-item-title">车票 / 机票 / 酒店确认</span><span class="cl-del">×</span></div>
<div class="cl-item"><span class="box"><svg viewBox="0 0 24 24" width="14" height="14" fill="none"><path d="M4 12.5l5 5L20 6.5" stroke="#fff" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/></svg></span><span class="cl-item-title">雨伞</span><span class="cl-del">×</span></div>
<div class="cl-item"><span class="box"><svg viewBox="0 0 24 24" width="14" height="14" fill="none"><path d="M4 12.5l5 5L20 6.5" stroke="#fff" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/></svg></span><span class="cl-item-title">工牌 / 名片</span><span class="cl-del">×</span></div>
<div class="cl-item"><span class="box"><svg viewBox="0 0 24 24" width="14" height="14" fill="none"><path d="M4 12.5l5 5L20 6.5" stroke="#fff" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/></svg></span><span class="cl-item-title">报销单据</span><span class="cl-del">×</span></div>
<div class="cl-item"><span class="box"><svg viewBox="0 0 24 24" width="14" height="14" fill="none"><path d="M4 12.5l5 5L20 6.5" stroke="#fff" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/></svg></span><span class="cl-item-title">现金零钱</span><span class="cl-del">×</span></div>
<div class="cl-add"><div class="inp ph">添加检查项</div><div class="mini-btn solid">添加</div></div>
<div class="cl-actions">
<span class="link">全选</span><span class="link">全不选</span><span class="link">复制</span>
<span class="link">存为模板</span><span class="link">归档</span><span class="link danger">删除</span>
</div>
</div>
</div>
<div class="checklist-card">
<div class="cl-title-row"><span class="cl-title">回老家 · 国庆</span><span class="cl-scene">回老家</span></div>
<div class="progress-row">
<div class="bar"><i style="width:0%"></i></div><span class="count">0/12</span><span class="arrow" style="transform:none">›</span>
</div>
</div>
</div>
<div class="tabbar">
<div class="tab"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M4 11l8-6 8 6v8a1 1 0 0 1-1 1h-4v-6H9v6H5a1 1 0 0 1-1-1z"/></svg>首页</div>
<div class="tab"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M4 19V9m5 10V5m5 14v-7m5 7V8"/></svg>宏观</div>
<div class="tab"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><rect x="3" y="4" width="18" height="7" rx="2"/><rect x="3" y="13" width="18" height="7" rx="2"/><circle cx="7" cy="7.5" r="1"/><circle cx="7" cy="16.5" r="1"/></svg>监控</div>
<div class="tab on"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><circle cx="12" cy="12" r="8.5"/><path d="M8 12.4l2.6 2.6L16.5 9.5" stroke-linecap="round" stroke-linejoin="round"/></svg>打卡</div>
<div class="tab"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><circle cx="12" cy="8" r="3.6"/><path d="M4.5 20c1.4-3.6 4.1-5.4 7.5-5.4S18.1 16.4 19.5 20"/></svg>我</div>
</div>
</div>
<div class="caption">
<b>2.3 清单视图 · 展开态</b>
<p>卡片头部常显标题、场景标签、进度条与 <code>8/12</code>;点击展开逐条勾选(勾选后绿色圆底、文字加删除线并弱化)。底部可继续添加检查项,并提供全选 / 全不选 / 复制 / 存为模板 / 归档 / 删除。收起态只保留头部一行。</p>
</div>
</div>
<!-- ---------- 2.4 新建打卡项弹窗 ---------- -->
<div class="shot">
<div class="phone">
<div class="navbar">打卡</div>
<div class="body">
<div class="habit-head">
<div>
<div class="habit-date">10月22日 周四</div>
<div class="habit-summary">已完成 <strong>2</strong> / 4</div>
</div>
<div class="head-right"><div class="mini-btn">特效开</div><div class="mini-btn solid">+ 新建</div></div>
</div>
<div class="habit-list">
<div class="habit-card">
<div class="ring">
<svg width="58" height="58" viewBox="0 0 58 58">
<circle cx="29" cy="29" r="26" fill="none" stroke="#ececf0" stroke-width="6"/>
<circle cx="29" cy="29" r="26" fill="none" stroke="#1677ff" stroke-width="6" stroke-linecap="round" stroke-dasharray="163.4" stroke-dashoffset="163.4" transform="rotate(-90 29 29)"/>
</svg><div class="ring-center">0<em>/2</em></div>
</div>
<div class="habit-main"><div class="habit-name">喝水</div><div class="habit-meta">每天 2 次</div><div class="habit-range">今日</div></div>
<div class="habit-actions"><div class="mini-btn solid">打卡</div></div>
<div class="habit-foot"><span class="link">编辑</span><span class="link danger">归档</span></div>
</div>
</div>
</div>
<div class="mask"></div>
<div class="sheet">
<div class="sheet-title">新建打卡项</div>
<div class="field"><label>名称</label><div class="inp ph">例如:喝水 / 健身 / 读书</div></div>
<div class="field"><label>周期</label>
<div class="chips"><div class="chip on">每天</div><div class="chip">每周</div><div class="chip">每月</div><div class="chip">每年</div></div>
</div>
<div class="field"><label>目标次数(周期内)</label>
<div class="stepper"><span>−</span><b>1</b><span class="act">+</span></div>
</div>
<div class="field"><label>颜色</label>
<div class="colors">
<span class="cd on" style="background:#1677ff"></span><span class="cd" style="background:#00b578"></span>
<span class="cd" style="background:#ff8f1f"></span><span class="cd" style="background:#ff3141"></span>
<span class="cd" style="background:#a855f7"></span><span class="cd" style="background:#14b8a6"></span>
<span class="cd" style="background:#f43f9e"></span>
</div>
</div>
<div class="field"><label>备注(可选)</label><div class="inp ph">补充说明</div></div>
<div class="btn-primary">保存</div>
<div class="btn-ghost">取消</div>
</div>
<div class="tabbar">
<div class="tab"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M4 11l8-6 8 6v8a1 1 0 0 1-1 1h-4v-6H9v6H5a1 1 0 0 1-1-1z"/></svg>首页</div>
<div class="tab"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M4 19V9m5 10V5m5 14v-7m5 7V8"/></svg>宏观</div>
<div class="tab"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><rect x="3" y="4" width="18" height="7" rx="2"/><rect x="3" y="13" width="18" height="7" rx="2"/><circle cx="7" cy="7.5" r="1"/><circle cx="7" cy="16.5" r="1"/></svg>监控</div>
<div class="tab on"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><circle cx="12" cy="12" r="8.5"/><path d="M8 12.4l2.6 2.6L16.5 9.5" stroke-linecap="round" stroke-linejoin="round"/></svg>打卡</div>
<div class="tab"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><circle cx="12" cy="8" r="3.6"/><path d="M4.5 20c1.4-3.6 4.1-5.4 7.5-5.4S18.1 16.4 19.5 20"/></svg>我</div>
</div>
</div>
<div class="caption">
<b>2.4 新建 / 编辑打卡项(底部弹层)</b>
<p>名称 ≤20 字;周期四选一(每天 / 每周 / 每月 / 每年);目标次数用 Stepper,最小 1;颜色 7 色预设;备注可选。编辑态标题变为「编辑打卡项」并回填当前值。</p>
</div>
</div>
<!-- ---------- 2.5 完成特效 ---------- -->
<div class="shot">
<div class="phone">
<div class="navbar">打卡</div>
<div class="body">
<div class="habit-head">
<div><div class="habit-date">10月22日 周四</div><div class="habit-summary">已完成 <strong>3</strong> / 4</div></div>
<div class="head-right"><div class="mini-btn">特效开</div><div class="mini-btn solid">+ 新建</div></div>
</div>
<div class="habit-list">
<div class="habit-card done" style="overflow:visible">
<div class="ring">
<!-- 特效层:光环 + 14 颗粒子(静态呈现 t=250ms 时的形态) -->
<div class="fx">
<div class="halo"></div><div class="halo halo2"></div>
<i style="background:#ffc53d;transform:translate(-50%,-50%) rotate(0deg) translateY(-34px)"></i>
<i style="background:#00b578;transform:translate(-50%,-50%) rotate(26deg) translateY(-40px)"></i>
<i style="background:#00b578;transform:translate(-50%,-50%) rotate(52deg) translateY(-34px)"></i>
<i style="background:#ffc53d;transform:translate(-50%,-50%) rotate(78deg) translateY(-46px)"></i>
<i style="background:#00b578;transform:translate(-50%,-50%) rotate(104deg) translateY(-34px)"></i>
<i style="background:#00b578;transform:translate(-50%,-50%) rotate(130deg) translateY(-40px)"></i>
<i style="background:#ffc53d;transform:translate(-50%,-50%) rotate(156deg) translateY(-34px)"></i>
<i style="background:#00b578;transform:translate(-50%,-50%) rotate(182deg) translateY(-46px)"></i>
<i style="background:#00b578;transform:translate(-50%,-50%) rotate(208deg) translateY(-34px)"></i>
<i style="background:#ffc53d;transform:translate(-50%,-50%) rotate(234deg) translateY(-40px)"></i>
<i style="background:#00b578;transform:translate(-50%,-50%) rotate(260deg) translateY(-34px)"></i>
<i style="background:#00b578;transform:translate(-50%,-50%) rotate(286deg) translateY(-46px)"></i>
<i style="background:#ffc53d;transform:translate(-50%,-50%) rotate(312deg) translateY(-34px)"></i>
<i style="background:#00b578;transform:translate(-50%,-50%) rotate(338deg) translateY(-40px)"></i>
</div>
<svg width="58" height="58" viewBox="0 0 58 58">
<circle cx="29" cy="29" r="26" fill="none" stroke="#ececf0" stroke-width="6"/>
<circle cx="29" cy="29" r="26" fill="none" stroke="#00b578" stroke-width="6" stroke-linecap="round" stroke-dasharray="163.4" stroke-dashoffset="0" transform="rotate(-90 29 29)"/>
</svg>
<div class="ring-center">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none"><path d="M4 12.5l5 5L20 6.5" stroke="#00b578" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/></svg>
</div>
</div>
<div class="habit-main"><div class="habit-name">吃药</div><div class="habit-meta">每天 1 次<span class="streak">连续 30</span></div><div class="habit-range">今日</div></div>
<div class="habit-actions"><div class="mini-btn grey">再打一次</div></div>
<div class="habit-foot"><span class="link">撤销</span><span class="link">编辑</span><span class="link danger">归档</span></div>
</div>
<div class="habit-card">
<div class="ring">
<svg width="58" height="58" viewBox="0 0 58 58">
<circle cx="29" cy="29" r="26" fill="none" stroke="#ececf0" stroke-width="6"/>
<circle cx="29" cy="29" r="26" fill="none" stroke="#ff8f1f" stroke-width="6" stroke-linecap="round" stroke-dasharray="163.4" stroke-dashoffset="108.9" transform="rotate(-90 29 29)"/>
</svg><div class="ring-center">1<em>/3</em></div>
</div>
<div class="habit-main"><div class="habit-name">健身</div><div class="habit-meta">每周 3 次</div><div class="habit-range">10-19 ~ 10-25</div></div>
<div class="habit-actions"><div class="mini-btn solid">打卡</div></div>
<div class="habit-foot"><span class="link">撤销</span><span class="link">编辑</span><span class="link danger">归档</span></div>
</div>
</div>
</div>
<div class="tabbar">
<div class="tab"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M4 11l8-6 8 6v8a1 1 0 0 1-1 1h-4v-6H9v6H5a1 1 0 0 1-1-1z"/></svg>首页</div>
<div class="tab"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M4 19V9m5 10V5m5 14v-7m5 7V8"/></svg>宏观</div>
<div class="tab"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><rect x="3" y="4" width="18" height="7" rx="2"/><rect x="3" y="13" width="18" height="7" rx="2"/><circle cx="7" cy="7.5" r="1"/><circle cx="7" cy="16.5" r="1"/></svg>监控</div>
<div class="tab on"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><circle cx="12" cy="12" r="8.5"/><path d="M8 12.4l2.6 2.6L16.5 9.5" stroke-linecap="round" stroke-linejoin="round"/></svg>打卡</div>
<div class="tab"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><circle cx="12" cy="8" r="3.6"/><path d="M4.5 20c1.4-3.6 4.1-5.4 7.5-5.4S18.1 16.4 19.5 20"/></svg>我</div>
</div>
</div>
<div class="caption">
<b>2.5 达标瞬间 · 完成特效</b>
<p>环形闭合 + 对勾缩放入场(0.32s);同时 14 颗粒子沿圆周扩散(26~58px,交替主色与金色)+ 主色光环脉冲(0.7s),总时长 ≤900ms,<code>pointer-events:none</code> 不阻塞后续操作;并触发 18ms 轻震动。可在头部一键关闭(持久化开关),系统「减弱动效」时自动降级。</p>
</div>
</div>
<!-- ---------- 2.6 日历·明天(计划) ---------- -->
<div class="shot">
<div class="phone">
<div class="navbar">打卡</div>
<div class="body">
<div class="seg" style="width:calc(100% - 28px);margin-left:14px"><div>打卡</div><div class="on">日历</div><div>清单</div></div>
<div class="cal-month-bar"><span class="cal-arrow">‹</span><span class="cal-month">2026年10月</span><span class="cal-arrow">›</span><span class="cal-today">回到今天</span></div>
<div class="seg"><div>昨天</div><div>今天</div><div class="on">明天</div></div>
<div class="cal-grid">
<div class="grid7 wd"><span>一</span><span>二</span><span>三</span><span>四</span><span>五</span><span>六</span><span>日</span></div>
<div class="grid7">
<div class="day out"><span class="num">28</span></div><div class="day out"><span class="num">29</span></div><div class="day out"><span class="num">30</span></div>
<div class="day"><span class="num">1</span><i class="dot full"></i></div>
<div class="day"><span class="num">2</span><i class="dot full"></i></div>
<div class="day"><span class="num">3</span><i class="dot partial"></i></div>
<div class="day"><span class="num">4</span><i class="dot full"></i></div>
<div class="day"><span class="num">5</span></div>
<div class="day"><span class="num">6</span><i class="dot full"></i></div>
<div class="day"><span class="num">7</span><i class="dot over"></i></div>
<div class="day"><span class="num">8</span><i class="dot full"></i></div>
<div class="day"><span class="num">9</span><i class="dot partial"></i></div>
<div class="day"><span class="num">10</span><i class="dot full"></i></div>
<div class="day"><span class="num">11</span><i class="dot full"></i></div>
<div class="day"><span class="num">12</span></div><div class="day"><span class="num">13</span></div>
<div class="day"><span class="num">14</span><i class="dot full"></i></div>
<div class="day"><span class="num">15</span><i class="dot full"></i></div>
<div class="day"><span class="num">16</span><i class="dot partial"></i></div>
<div class="day"><span class="num">17</span><i class="dot full"></i></div>
<div class="day"><span class="num">18</span><i class="dot full"></i></div>
<div class="day"><span class="num">19</span><i class="dot full"></i></div>
<div class="day"><span class="num">20</span><i class="dot full"></i></div>
<div class="day today"><span class="num">21</span><i class="dot full"></i></div>
<div class="day"><span class="num">22</span><i class="dot partial"></i></div>
<div class="day future sel"><span class="num">23</span></div>
<div class="day future"><span class="num">24</span></div>
<div class="day future"><span class="num">25</span></div>
<div class="day future"><span class="num">26</span></div><div class="day future"><span class="num">27</span></div>
<div class="day future"><span class="num">28</span></div><div class="day future"><span class="num">29</span></div>
<div class="day future"><span class="num">30</span></div><div class="day future"><span class="num">31</span></div>
<div class="day out"><span class="num">1</span></div>
</div>
</div>
<div class="cal-rate">本月完成率 <strong>82%</strong></div>
<div class="cal-detail-title">明天 · 2026-10-23<span class="badge">计划(不可打卡)</span></div>
<div class="cal-item"><i class="cdot" style="background:#1677ff"></i><span class="cname">喝水</span><span class="cmeta">每天 2 次 · 当日 0/2</span><span class="cplan">待办</span></div>
<div class="cal-item"><i class="cdot" style="background:#ff8f1f"></i><span class="cname">健身</span><span class="cmeta">每周 3 次 · 当日 0/1</span><span class="cplan">待办</span></div>
<div class="cal-item"><i class="cdot" style="background:#00b578"></i><span class="cname">读书</span><span class="cmeta">每月 12 次 · 当日 0/1</span><span class="cplan">待办</span></div>
<div class="cal-item"><i class="cdot" style="background:#a855f7"></i><span class="cname">吃药</span><span class="cmeta">每天 1 次 · 当日 0/1</span><span class="cplan">待办</span></div>
</div>
<div class="tabbar">
<div class="tab"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M4 11l8-6 8 6v8a1 1 0 0 1-1 1h-4v-6H9v6H5a1 1 0 0 1-1-1z"/></svg>首页</div>
<div class="tab"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M4 19V9m5 10V5m5 14v-7m5 7V8"/></svg>宏观</div>
<div class="tab"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><rect x="3" y="4" width="18" height="7" rx="2"/><rect x="3" y="13" width="18" height="7" rx="2"/><circle cx="7" cy="7.5" r="1"/><circle cx="7" cy="16.5" r="1"/></svg>监控</div>
<div class="tab on"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><circle cx="12" cy="12" r="8.5"/><path d="M8 12.4l2.6 2.6L16.5 9.5" stroke-linecap="round" stroke-linejoin="round"/></svg>打卡</div>
<div class="tab"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><circle cx="12" cy="8" r="3.6"/><path d="M4.5 20c1.4-3.6 4.1-5.4 7.5-5.4S18.1 16.4 19.5 20"/></svg>我</div>
</div>
</div>
<div class="caption">
<b>2.6 日历 · 明天(计划,只读)</b>
<p>未来日期不允许打卡:标题右侧显示「计划(不可打卡)」徽标,条目右侧只显示「待办」文案而不渲染按钮,月历上未来日期数字弱化且不打点。昨天视图则相反——可补打最近 7 天内的漏打卡。</p>
</div>
</div>
<!-- ---------- 2.7 清单完成 + 空态 ---------- -->
<div class="shot">
<div class="phone">
<div class="navbar">打卡</div>
<div class="body">
<div class="seg" style="width:calc(100% - 28px);margin-left:14px"><div>打卡</div><div>日历</div><div class="on">清单</div></div>
<div class="list-head">
<div class="seg"><div class="on">进行中</div><div>已归档</div></div>
<div class="mini-btn solid">+ 新建</div>
</div>
<div class="checklist-card done" style="position:relative;overflow:visible">
<div class="cl-title-row"><span class="cl-title">出差 · 杭州</span><span class="cl-scene">出差</span></div>
<div class="progress-row"><div class="bar"><i style="width:100%"></i></div><span class="count">12/12</span><span class="arrow">›</span></div>
<div class="fx" style="left:50%;top:34px;right:auto;bottom:auto;width:0;height:0">
<div class="halo" style="border-color:#00b578"></div>
<i style="background:#ffc53d;transform:translate(-50%,-50%) rotate(10deg) translateY(-46px)"></i>
<i style="background:#00b578;transform:translate(-50%,-50%) rotate(46deg) translateY(-56px)"></i>
<i style="background:#00b578;transform:translate(-50%,-50%) rotate(92deg) translateY(-46px)"></i>
<i style="background:#ffc53d;transform:translate(-50%,-50%) rotate(140deg) translateY(-62px)"></i>
<i style="background:#00b578;transform:translate(-50%,-50%) rotate(190deg) translateY(-46px)"></i>
<i style="background:#00b578;transform:translate(-50%,-50%) rotate(240deg) translateY(-56px)"></i>
<i style="background:#ffc53d;transform:translate(-50%,-50%) rotate(300deg) translateY(-46px)"></i>
<i style="background:#00b578;transform:translate(-50%,-50%) rotate(340deg) translateY(-62px)"></i>
</div>
</div>
<div class="checklist-card">
<div class="cl-title-row"><span class="cl-title">回老家 · 国庆</span><span class="cl-scene">回老家</span></div>
<div class="progress-row"><div class="bar"><i style="width:0%"></i></div><span class="count">0/12</span><span class="arrow" style="transform:none">›</span></div>
</div>
</div>
<div class="tabbar">
<div class="tab"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M4 11l8-6 8 6v8a1 1 0 0 1-1 1h-4v-6H9v6H5a1 1 0 0 1-1-1z"/></svg>首页</div>
<div class="tab"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M4 19V9m5 10V5m5 14v-7m5 7V8"/></svg>宏观</div>
<div class="tab"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><rect x="3" y="4" width="18" height="7" rx="2"/><rect x="3" y="13" width="18" height="7" rx="2"/><circle cx="7" cy="7.5" r="1"/><circle cx="7" cy="16.5" r="1"/></svg>监控</div>
<div class="tab on"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><circle cx="12" cy="12" r="8.5"/><path d="M8 12.4l2.6 2.6L16.5 9.5" stroke-linecap="round" stroke-linejoin="round"/></svg>打卡</div>
<div class="tab"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><circle cx="12" cy="8" r="3.6"/><path d="M4.5 20c1.4-3.6 4.1-5.4 7.5-5.4S18.1 16.4 19.5 20"/></svg>我</div>
</div>
</div>
<div class="caption">
<b>2.7 清单全部完成(12/12)</b>
<p>进度条 100%、计数变为 <code>12/12</code>、卡片底色转 <code>#f7fbf8</code>,并播放与打卡一致的完成特效。清单完成后用户通常选择「归档」或「复制」复用。</p>
</div>
</div>
<!-- ---------- 2.8 空态与归档 ---------- -->
<div class="shot">
<div class="phone">
<div class="navbar">打卡</div>
<div class="body">
<div class="habit-head">
<div><div class="habit-date">10月22日 周四</div><div class="habit-summary">已完成 <strong>0</strong> / 0</div></div>
<div class="head-right"><div class="mini-btn">特效关</div><div class="mini-btn solid">+ 新建</div></div>
</div>
<div class="empty">
<div class="ico">○</div>
<p>还没有打卡项</p>
<div style="display:inline-block" class="mini-btn solid">创建第一个打卡项</div>
</div>
</div>
<div class="tabbar">
<div class="tab"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M4 11l8-6 8 6v8a1 1 0 0 1-1 1h-4v-6H9v6H5a1 1 0 0 1-1-1z"/></svg>首页</div>
<div class="tab"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M4 19V9m5 10V5m5 14v-7m5 7V8"/></svg>宏观</div>
<div class="tab"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><rect x="3" y="4" width="18" height="7" rx="2"/><rect x="3" y="13" width="18" height="7" rx="2"/><circle cx="7" cy="7.5" r="1"/><circle cx="7" cy="16.5" r="1"/></svg>监控</div>
<div class="tab on"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><circle cx="12" cy="12" r="8.5"/><path d="M8 12.4l2.6 2.6L16.5 9.5" stroke-linecap="round" stroke-linejoin="round"/></svg>打卡</div>
<div class="tab"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><circle cx="12" cy="8" r="3.6"/><path d="M4.5 20c1.4-3.6 4.1-5.4 7.5-5.4S18.1 16.4 19.5 20"/></svg>我</div>
</div>
</div>
<div class="caption">
<b>2.8 空态</b>
<p>三个视图均具备加载中 / 空态 / 错误(带重试)三态。空态给出明确引导按钮;头部「特效开 / 特效关」为全局动效开关,关闭后仅保留环形与对勾,不播放粒子与震动。</p>
</div>
</div>
</div>
<!-- ============ 3 组件状态 ============ -->
<h2>3 组件状态定义<span class="sub">Component States</span></h2>
<h3>3.1 环形进度(尺寸 58,环宽 6,r=26,周长 163.4)</h3>
<table>
<tr><th>状态</th><th>条件</th><th>环形</th><th>中心</th><th>卡片底</th><th>按钮</th></tr>
<tr><td>未开始</td><td><code>done = 0</code></td><td>仅灰底环</td><td><code>0/2</code></td><td>白</td><td>打卡(主色)</td></tr>
<tr><td>进行中</td><td><code>0 &lt; done &lt; target</code></td><td>按 <code>done/target</code> 填充(45°…330°)</td><td><code>1/3</code></td><td>白</td><td>打卡(主色)</td></tr>
<tr><td>已达标</td><td><code>done = target</code></td><td>闭合整圈</td><td>对勾(主色)</td><td><code>#f7fbf8</code></td><td>再打一次(灰)</td></tr>
<tr><td>超额</td><td><code>done &gt; target</code></td><td>闭合 + 右上角 <code>+n</code> 橙标</td><td>对勾</td><td><code>#f7fbf8</code></td><td>再打一次(灰)</td></tr>
<tr><td>每日 1 次达标</td><td><code>target = 1</code> 且已打卡</td><td><b>直接整圈闭合</b>,不做渐增生长期</td><td>对勾</td><td><code>#f7fbf8</code></td><td>再打一次</td></tr>
</table>
<p class="note">环形进度 = 周期完成度(<code>done / n</code>);日历日标记 = 当日份额(<code>当日次数 / 日均期望</code>),两者口径不同,互不影响。</p>
<h3>3.2 日历日期</h3>
<table>
<tr><th>状态</th><th>判定</th><th>视觉</th></tr>
<tr><td>全部完成 <code>full</code></td><td>所有打卡项当日次数 ≥ 日均期望,且无超额</td><td>绿色圆点</td></tr>
<tr><td>超额 <code>over</code></td><td>全部达标且存在任一打卡项当日次数 &gt; 期望</td><td>紫色圆点</td></tr>
<tr><td>部分完成 <code>partial</code></td><td>有完成但未全部完成</td><td>黄色圆点</td></tr>
<tr><td>无 <code>empty</code></td><td>无记录 / 当天无打卡项 / 未来日期</td><td>不显示圆点</td></tr>
<tr><td>今天</td><td><code>date = today</code></td><td>数字主色加粗</td></tr>
<tr><td>选中</td><td>用户点击</td><td>主色实心底,数字与圆点转白</td></tr>
<tr><td>非本月 / 未来</td><td>月份不符 / <code>date &gt; today</code></td><td>数字弱化(#c8c8c8 / #b0b0b0)</td></tr>
</table>
<h3>3.3 清单检查项</h3>
<table>
<tr><th>状态</th><th>勾选框</th><th>文字</th><th>触发</th></tr>
<tr><td>未完成</td><td>1.5px 灰边圆圈,对勾透明</td><td>#333</td><td>点击圆圈或文字切换</td></tr>
<tr><td>已完成</td><td>绿底绿边,白色对勾显现(0.2s)</td><td>#a0a0a0 + 删除线</td><td>同上,写 <code>doneAt</code></td></tr>
<tr><td>整单完成</td><td>—</td><td>进度条 100%、卡片转绿</td><td>最后一项勾选时播放特效</td></tr>
</table>
<!-- ============ 4 交互与动效 ============ -->
<h2>4 交互与动效<span class="sub">Motion</span></h2>
<table>
<tr><th>动效</th><th>时长</th><th>曲线 / 实现</th><th>说明</th></tr>
<tr><td>环形增长</td><td>450ms</td><td><code>cubic-bezier(.4,0,.2,1)</code>,<code>stroke-dashoffset</code> 过渡</td><td>12 点方向顺时针增长</td></tr>
<tr><td>对勾入场</td><td>320ms</td><td><code>scale(.4) → 1</code> + 透明度</td><td>达标瞬间出现</td></tr>
<tr><td>完成粒子</td><td>≤900ms</td><td>14 颗粒子按角度 <code>--a</code>、距离 <code>--d</code> 扩散,交错 40ms 延迟</td><td>不阻塞点击</td></tr>
<tr><td>光环脉冲</td><td>700ms</td><td>主色圆环放大淡出</td><td>与粒子同时开始</td></tr>
<tr><td>触感反馈</td><td>18ms</td><td><code>navigator.vibrate(18)</code></td><td>WebView 不支持则静默跳过</td></tr>
<tr><td>进度条填充</td><td>400ms</td><td><code>width</code> 过渡</td><td>清单勾选后更新</td></tr>
<tr><td>视图切换</td><td>—</td><td>SwipeTabs 跟手横滑</td><td>与既有模块一致</td></tr>
</table>
<h3>4.1 打卡时序</h3>
<div class="flow">用户点环 / 点「打卡」
└─ 前端乐观更新:done+1,重算 percent / completed / over,环形立即增长
└─ POST /api/checkin/habits/:id/check
├─ 200 → 用服务端 item 覆盖本地;若 completed → 触发特效 + 震动
├─ 400 → 回滚 + Toast(未来日期 / 超出 7 天补打窗口)
├─ 409 → 视为已打卡,刷新一次(幂等)
└─ 网络失败 → 回滚 + Toast「打卡失败」</div>
<!-- ============ 5 适配与可达性 ============ -->
<h2>5 适配与可达性</h2>
<table>
<tr><th>项</th><th>约定</th></tr>
<tr><td>视口</td><td>设计稿 420×820;375~430px 内元素不换行、不裁切;底部 TabBar 5 项每项 ≥ 40px 可点宽度</td></tr>
<tr><td>安全区</td><td>TabBar 使用 antd-mobile <code>safeArea</code>,页面底部留出 <code>tabbar-h + safe-bottom</code></td></tr>
<tr><td>减弱动效</td><td>系统开启「减弱动效」或用户关闭特效开关 → 不播放粒子 / 光环 / 震动,只保留环形与对勾</td></tr>
<tr><td>大字号</td><td>卡片采用 grid 自适应列宽,文字超长换行不截断;名称限制 20 字、检查项 40 字</td></tr>
<tr><td>深色模式</td><td>V0.2 沿用现有浅色主题,暂不适配</td></tr>
</table>
<p class="note" style="margin-top:26px">本文档为静态设计稿,不含脚本逻辑;实现对应 <code>src/pages/checkin/*.jsx</code>、<code>src/components/RingProgress.jsx</code> 与样式段(<code>src/styles/globals.css</code>)。接口与数据口径见《设计文档.md》与《数据库设计文档.md》。</p>
</div>
</body>
</html>
+338
View File
@@ -0,0 +1,338 @@
# iBoard V0.2 设计文档 — 打卡 / 日历 / 清单
| 项 | 内容 |
| --- | --- |
| 版本 | V0.2(对应分支 `dev-0.2`) |
| 文档状态 | 已实现(与代码一致) |
| 上游文档 | `需求文档.md`(需求编号 F-CK / F-CL / F-LS / F-SY / U / D / N / E 沿用本文) |
| 配套文档 | `数据库设计文档.md`、`界面设计.html` |
| 技术栈 | Tauri 2 + React 18 + antd-mobile + React Router v6 + Express 4 + Prisma/MariaDB |
| 设计基线 | 移动端优先,视口 420×820,375~430px 自适应;时区统一 `Asia/Shanghai` |
---
## 1. 总体设计
### 1.1 目标与范围
在既有 iBoard(首页 / 宏观 / 监控 / 我)之上新增**第 5 个底部 Tab「打卡」**,页面内以三个子视图承载三类需求:
| 子视图 | 解决问题 | 核心隐喻 |
| --- | --- | --- |
| **打卡** | 周期性习惯的量化坚持 | 环形进度,点环即打卡 |
| **日历** | 回看昨天、执行今天、预知明天 | 月历网格 + 日状态点 |
| **清单** | 出差/回老家等一次性场景的漏项检查 | 勾选清单 + 进度条 |
**不纳入 V0.2**:多用户共享清单、提醒推送、数据导出、打卡项分组、年视图热力图(见 §9 演进)。
### 1.2 架构总览
```
┌──────────────────────── 前端 (React + antd-mobile, 打包进 Tauri / OTA) ────────────────────────┐
│ App.jsx 路由 │
│ └ /checkin ──► CheckinPage ── SwipeTabs ──┬── HabitView (打卡) │
│ ├── CalendarView (日历) │
│ └── ListView (清单) │
│ 组件: RingProgress(环形) / Celebrate(特效) / BottomNav / TabIcon │
│ 工具: src/pages/checkin/date.js (本地日期, 与后端同口径) apiClient: src/api/checkin.js │
└──────────────────────────────────┬─────────────────────────────────────────────────────────────┘
│ HTTPS + JWT (Authorization: Bearer)
┌──────────────────────────────────▼─────────────────────────────────────────────────────────────┐
│ Express (server/index.js) │
│ /api/checkin ─► routes/checkin.js ─┐ │
│ /api/checklists─► routes/checklists.js ─┤─► middleware.authRequired ─► Prisma Client │
│ └─► checkin/period.js (周期口径唯一来源) │
└──────────────────────────────────┬─────────────────────────────────────────────────────────────┘
▼
MariaDB: Habit / CheckIn / CheckList / CheckListItem / CheckListTemplate
```
### 1.3 分层与职责
| 层 | 位置 | 职责 |
| --- | --- | --- |
| 页面 | `src/pages/checkin/*.jsx` | 视图编排、本地状态、乐观更新与回滚 |
| 组件 | `src/components/RingProgress.jsx` | 纯展示:SVG 环形 + 完成特效 + 特效开关 |
| 日期工具 | `src/pages/checkin/date.js` | 与后端同口径的本地日期运算(**改一处需同步另一处**) |
| API 封装 | `src/api/checkin.js` | 统一走 `api()`(自带 token / 错误解析) |
| 路由 | `server/routes/checkin.js`、`checklists.js` | 参数校验、鉴权、组装展示态 |
| 领域逻辑 | `server/checkin/period.js` | 周期窗口、日均期望、streak 判定 |
| 持久化 | Prisma Client(`generated/prisma`) | 表操作 |
### 1.4 目录结构(本次新增/修改)
```
prisma/
schema.prisma # +5 个 model,User 加反向关系
migrations/20261022120000_add_checkin_checklist/ # 新建表 + 外键
server/
checkin/period.js # 周期计算(唯一口径)
routes/checkin.js # /api/checkin/*
routes/checklists.js # /api/checklists/*
index.js # 挂载两个路由
src/
api/checkin.js # checkinApi / checklistApi
components/RingProgress.jsx # 环形进度 + Celebrate 特效
components/BottomNav.jsx # 新增「打卡」Tab
components/TabIcon.jsx # 新增 checkin 图标
pages/checkin/CheckinPage.jsx # 容器:三视图切换
pages/checkin/HabitView.jsx # 打卡视图
pages/checkin/CalendarView.jsx # 日历视图
pages/checkin/ListView.jsx # 清单视图
pages/checkin/date.js # 前端日期工具
App.jsx # 新增 /checkin 路由
styles/globals.css # 打卡相关样式段
docs/V0.2/ # 需求 / 设计 / 数据库设计 / 界面设计
```
---
## 2. 领域模型与核心口径
### 2.1 实体
`Habit`(打卡项定义)· `CheckIn`(一次打卡)· `CheckList`(场景清单)· `CheckListItem`(检查项)· `CheckListTemplate`(模板)。字段与索引见《数据库设计文档》。
### 2.2 周期口径(唯一来源:`server/checkin/period.js`)
| 概念 | 定义 |
| --- | --- |
| 本地日历日 | `Intl.DateTimeFormat('en-CA', { timeZone: 'Asia/Shanghai' })` → `"YYYY-MM-DD"` |
| 周起始 | **周一**(`startOfWeek` 把周日映射到第 7 天) |
| 周期窗口 | day = [d, d];week = [周一, 周日];month = [1 号, 月末];year = [1/1, 12/31] |
| **环形进度** | `percent = 周期内累计次数 / n`,上限 1 —— 回答「这周期做了多少」 |
| **日均期望次数** | day→n;week→`ceil(n/7)`;month→`ceil(n/当月天数)`;year→`ceil(n/365)` —— 回答「今天该做多少」 |
| 完成 | `done >= target`(超额允许,`over = done - target` 继续累加) |
| streak | 从当前周期向前回溯:达标 +1;**当前周期未结束且未达标 → 跳过而非中断**;已结束周期未达标 → 终止 |
>两种口径并存是有意为之:环形看「周期整体」,日历日标记看「当天份额」。例如「每周 3 次」,周三打 1 次 → 环形 33.3%,当天已达标(1 ≥ ceil(3/7)=1)。
### 2.3 边界规则
| 场景 | 规则 | 编号 |
| --- | --- | --- |
| 提前打卡(未来日期) | 拒绝,返回 400「不能为未来日期打卡」 | F-CK-11 |
| 补打 | 仅限最近 **7 天**,记录 `source='backfill'` | F-CK-10 |
| 跨零点 | 以服务端 Asia/Shanghai 日历日为准,前端不自行判断 | E-02 |
| 周期中途新建 | 按完整 n 计算,不按剩余天数折算 | E-01 |
| 编辑 `target` | 立即生效,历史记录不改动 | F-CK-13 |
| 删除打卡项 | 默认归档(`archived=true`),`?hard=1` 物理删并清记录 | E-05 |
| 清单删除 | 物理删(一次性场景不留历史) | F-LS-10 |
---
## 3. 前端设计
### 3.1 路由与容器
```
/checkin → CheckinPage
├─ NavBar「打卡」
└─ SwipeTabs(左右滑动切换,与既有模块交互一致)
├─ habit → HabitView (受 date 影响)
├─ calendar → CalendarView (持有选中 date,可回写容器)
└─ list → ListView (独立)
```
`date` 状态提升到 `CheckinPage`:日历里点某天 → 切回打卡视图时即为该日,实现「日历与打卡联动」。
### 3.2 组件树与关键状态
```
CheckinPage
├── useState: active('habit'|'calendar'|'list'), date('YYYY-MM-DD')
HabitView(date)
├── data: { today, date, items[], summary:{total,done} }
├── bursts: { habitId: n } // 特效触发计数(+1 即播放一次)
├── form / showForm // 新建/编辑弹窗
└── effects: boolean // 特效开关(持久化 localStorage)
CalendarView(date, onDateChange)
├── month, cal(月历), detail(当日), bursts
└── quickValue: -1|0|1|null // 昨天/今天/明天 分段控件
ListView()
├── archived, lists, openId(展开的清单), newItem, templates, form/showForm
└── bursts
```
统一约定:
- 加载中 → `DotLoading`;错误 → `error-banner` + 重试;空 → antd-mobile `Empty` + 引导按钮(三态齐全,U-06)。
- 页面重新可见时静默刷新(`useRefreshOnVisible`),与既有模块一致。
### 3.3 环形进度组件 `RingProgress`
| 项 | 设计 |
| --- | --- |
| 实现 | 双 `<circle>`:灰底环 + `stroke-dasharray/dashoffset` 进度环,`rotate(-90)` 从 12 点顺时针 |
| 尺寸 | `size=58`,`stroke=6`,`r=26`,周长 `2πr≈163.4` |
| 动画 | `transition: stroke-dashoffset .45s cubic-bezier(.4,0,.2,1)`(400ms 级,U-01) |
| 中心内容 | 未达标 → `done / target` 数字;**达标 → 对勾 SVG**(`ring-check-in` 缩放入场);超额 → 右上角 `+n` 徽标 |
| 交互 | 整个环可点 = 打卡(`clickable`),同时卡片右侧保留显式「打卡」按钮,降低发现成本 |
| 无障碍/降级 | `disabled` 态半透明;系统「减弱动效」时特效自动关闭 |
**完成特效 `Celebrate`**(F-CK-07 / U-02):`fire` 计数器 +1 即触发一次 → 14 个粒子沿圆周角度扩散(`--a/--d` CSS 变量)+ 主色光环脉冲 0.7s,总时长 ≤900ms,`pointer-events:none` 不阻塞后续操作;同时 `navigator.vibrate(18)` 轻震动(U-03)。开关持久化在 `localStorage.iboard_celebrate`,`prefers-reduced-motion` 自动降级(U-07)。
### 3.4 交互清单
| 视图 | 主要交互 |
| --- | --- |
| 打卡 | 点环/点按钮打卡(乐观更新,失败回滚 + Toast)、撤销、编辑、归档、新建弹窗(名称/周期/目标次数/颜色/备注)、特效开关 |
| 日历 | 月份左右切换、回到今天、「昨天/今天/明天」分段切换、点日期选中、当日详情内打卡(未来日期只读显示「计划」)、本月完成率 |
| 清单 | 进行中/已归档切换、展开/收起、逐条勾选、添加检查项、全选/全不选、复制、存为模板、归档/恢复、删除、从模板新建 |
排序:打卡项列表**未完成在前**(F-CK-16);清单按 `updatedAt` 倒序。
### 3.5 视觉规范
| 类别 | 取值 |
| --- | --- |
| 主色 | `#1677ff`;成功 `#00b578`;警告/部分 `#ffc53d`;超额 `#a855f7`;危险 `#ff3141` |
| 背景 / 卡片 | `#f5f5f5` / `#ffffff`,卡片 `radius 10px` + `0 1px 2px rgba(0,0,0,.04)` |
| 文字 | 主 `#333`、次 `#666`、弱 `#999` |
| 日历日状态点 | full 绿 / partial 黄 / over 紫 / empty 无 |
| 打卡项预设色 | `#1677ff #00b578 #ff8f1f #ff3141 #a855f7 #14b8a6 #f43f9e` |
完整高保真稿见 `界面设计.html`。
---
## 4. 后端设计
### 4.1 通用约定
- 两个路由文件首行 `router.use(authRequired)`,**全部接口需登录**;用户维度一律取 `req.user.sub`,不信任 body 中的 `userId`。
- 响应统一 `{ item }` / `{ items }` / `{ ok }`;错误统一 `{ error: '中文提示' }` + 语义化状态码。
- 展示态(percent / completed / streak / dayDone …)由服务端计算后下发,前端不做二次口径推导,避免两端不一致。
### 4.2 接口清单
#### 打卡 `/api/checkin`
| 方法 | 路径 | 说明 | 关键响应字段 |
| --- | --- | --- | --- |
| GET | `/habits?date=` | 指定日待办 + 周期进度 | `items[]`(含 `done/target/percent/completed/over/streak/expectedPerDay/periodStart,End`)、`summary` |
| POST | `/habits` | 新建打卡项 | `item` |
| PUT | `/habits/:id` | 编辑(名称/周期/目标/颜色/备注/归档/排序) | `item` |
| DELETE | `/habits/:id?hard=1` | 默认归档;`hard=1` 物理删 | `{ ok, removed }` |
| POST | `/habits/:id/check` | 打一次卡(`{ date?, note? }`) | `item`(最新展示态) |
| DELETE | `/habits/:id/check?date=` | 撤销本周期最近一次 | `item` |
| GET | `/day?date=` | 某日每个打卡项明细 | `items[]`(含 `canCheck`)、`isToday`、`isFuture` |
| GET | `/calendar?month=YYYY-MM` | 月历 + 完成率 | `days[]`(`status/expected/actual/isToday/isFuture/habits[]`)、`rate`、`habits[]` |
#### 清单 `/api/checklists`
| 方法 | 路径 | 说明 |
| --- | --- | --- |
| GET | `/?archived=0|1` | 清单列表(带 `done/total/percent/completed`) |
| POST | `/` | 新建(`{ title, scene, note?, items?, templateId? }`) |
| GET | `/:id` | 单条详情 |
| PUT | `/:id` | 改名称/场景/备注/归档 |
| DELETE | `/:id` | 物理删除(连带检查项) |
| POST | `/:id/duplicate` | 复制为新清单(勾选状态重置) |
| POST | `/:id/items` | 新增检查项 |
| PUT | `/items/:id` | 改标题/备注/勾选(`done` 变更时写 `doneAt`) |
| DELETE | `/items/:id` | 删除检查项 |
| POST | `/:id/check-all` | 一键全选/全不选(`{ done }`) |
| GET | `/templates` | 模板列表(含系统预置,首次访问幂等写入) |
| POST | `/templates` | 另存为模板 |
| DELETE | `/templates/:id` | 删除**自建**模板(预置模板拒绝) |
### 4.3 关键流程:打卡
```
用户点环
│
├─ 前端: 本地 +1,重算 percent/completed/over(乐观更新)
│
└─ POST /api/checkin/habits/:id/check
├─ 校验 habit 归属 → 404
├─ date > today → 400 不能为未来日期打卡
├─ date < today-7 → 400 最多只能补打最近 7 天
├─ seq = max(seq)+1 → insert
│ └─ P2002 → 409 该次打卡已记录(幂等)
└─ 返回最新 item
│
├─ 成功: 用服务端 item 覆盖本地;若 completed → fire 特效
└─ 失败: setData(before) 回滚 + Toast
```
### 4.4 关键流程:月历聚合
1. 取全部 Habit(含归档)→ 过滤 `archived=false` 得 `active`。
2. 一次查询当月 `[01, 月末]` 全部 CheckIn,按 `habitId → date → count` 分组。
3. 逐日遍历:仅统计 `Habit.createdAt <= 该日` 的项;累加 `expected += expectedPerDay(...)`、`actual += min(count, expected)`;任一 `count > expected` 记 `over`。
4. `status`:全完成且无超额 → `full`;全完成且有超额 → `over`;有完成未全完成 → `partial`;否则 `empty`。
5. 完成率 `rate = Σactual / Σexpected`(**只统计非未来日期**)。
复杂度:`O(天数 × 打卡项数)`,30×20=600 次内存运算,无 N+1 查询。
### 4.5 校验与错误码
| 状态码 | 场景 |
| --- | --- |
| 400 | 名称为空/超长、周期非法、target < 1、未来日期打卡、超出补打窗口、本周期无记录可撤销、模板缺条目 |
| 404 | 资源不存在或不属于当前用户 |
| 409 | 并发重复打卡(P2002) |
| 401 | 未登录 / token 失效(`authRequired`) |
所有写操作均用 `prisma.xxx.findFirst({ where: { id, userId } })` 或 `where: { id, list: { userId } }` 做归属校验,杜绝越权改他人数据。
---
## 5. 非功能设计
| 维度 | 设计 |
| --- | --- |
| 首屏 | 三视图懒切换,只有激活视图发请求;单视图首屏 ≤ 1 次列表请求(N-01) |
| 弱网/失败 | 乐观更新 + 失败回滚 + Toast;错误横幅带重试;`useRefreshOnVisible` 静默刷新 |
| 幂等 | 唯一约束 `(habitId, date, seq)` + 前端按钮 loading,防连点重复计次 |
| 时区 | 全链路 Asia/Shanghai;库内 `DATETIME` 存 UTC,业务日期用 `VARCHAR(10)` 本地日历日 |
| 兼容 | 375~430px 自适应;Tauri Android WebView 支持 `navigator.vibrate`,不支持时静默跳过 |
| 性能 | 月历/周期统计均为「一次范围查询 + 内存聚合」,避免逐项查询 |
| 安全 | 全接口 `authRequired`;输入长度截断(名称 20、检查项 40、备注 200);无 SQL 拼接(Prisma 参数化) |
| 可维护 | 周期口径单点(`server/checkin/period.js`),前端 `date.js` 与之对齐,改动需同步两处(已在文档与注释中标注) |
---
## 6. 测试与验证
| 层级 | 内容 | 结果 |
| --- | --- | --- |
| 单元 | 周期工具 23 条断言(窗口边界、周一起始、月末、闰年、日均期望、streak 跳过未结束周期) | 全通过 |
| 接口冒烟 | 自签 JWT 端到端:每天 1 次闭合、每周 3 次 33.3%、超额 +1、撤销归零、未来日期 400、月历 31 天、模板生成 11 项、全选完成、复制重置 | 全通过,测试数据已清理 |
| 构建 | `npm run build` | 通过 |
| 迁移 | `prisma migrate deploy` 到目标库 | 成功 |
---
## 7. 里程碑(对应需求文档 M1~M5)
| 里程碑 | 内容 | 状态 |
| --- | --- | --- |
| M1 | 数据模型 + 迁移 + 周期模块 + 全部后端接口 | 完成 |
| M2 | 打卡视图:环形进度、特效、撤销/补打、新建编辑 | 完成 |
| M3 | 日历视图:月历 + 昨天/今天/明天 + 完成率 | 完成 |
| M4 | 清单视图:CRUD、勾选、进度、模板复用 | 完成 |
| M5 | 文档(需求/设计/数据库/界面)+ 构建校验 + 提交 | 完成 |
---
## 8. 风险与权衡
| 项 | 权衡 | 缓解 |
| --- | --- | --- |
| 周期口径前后端各实现一份 | 前端需离线即时反馈,不能等接口 | 两端算法同源同注释;口径变更必须同步 `period.js` 与 `date.js`(已写入项目记忆) |
| 进度实时聚合而非冗余计数 | 换取一致性与配置即时生效,牺牲少量 CPU | 单用户数据量极小(月历 30 天 × 20 项),实测无感;V0.3 可加汇总表 |
| 未来日期禁止打卡 | 防止「提前打完一周」刷进度,符合「日历看明天=计划」语义 | 明天视图只读展示「计划」,明确文案 |
| 特效可能打扰 | 部分用户不喜动效 | 一键开关 + `prefers-reduced-motion` 自动降级 |
| 新增第 5 个 Tab | 底部空间变拥挤 | 图标线性简洁;`TabBar` 原生 safe-area,5 项在 375px 下仍在可点区(≥44px) |
---
## 9. 后续演进(V0.3+)
1. **提醒与通知**:`Habit.remindAt` + Tauri 本地通知,解决「忘打卡」。
2. **补打放宽 / 历史编辑**:当前 7 天限制可配置化。
3. **统计视图**:连续天数榜单、月/年热力图(届时引入 `HabitDailyStat` 汇总表)。
4. **打卡项分组与排序拖拽**:现有 `sortOrder` 已预留,仅需交互。
5. **清单协作/分享**:引入 `CheckListShare`。
6. **数据导出**:清单完成后导出为图片或文本(便于发群里确认)。
+334
View File
@@ -0,0 +1,334 @@
# iBoard V0.2 需求文档 — 打卡 / 日历 / 清单
> 版本:V0.2 · 分支:`dev-0.2` · 日期:2026-10-22
> 目标项目:iboard(Tauri 2 + React 18 + antd-mobile + Express + Prisma/MariaDB,移动端优先)
> 基线版本:V0.1(`dev-0.1`)
---
## 1. 总体描述
在 iBoard 移动端新增一个「打卡」模块一个「清单」模块,满足两类日常需求:
### 打卡
1. **周期性打卡**(习惯养成):用户定义"每天 n 次 / 每周 n 次 / 每月 n 次 / 每年 n 次"的目标,
每次打卡后卡片上的**环形进度**按 `已完成次数 / 目标次数` 增长,达标时环形闭合并播放完成特效;
2. **日历回看**:以日历为载体,快速查看**今天**(要做什么)、**昨天**(做得怎样)、
**明天**(计划做什么),日期上以圆点/实心标记完成情况;
### 清单
1. **清单**(场景化一次性事项):面向**出差、回老家**等临时场景,逐条勾选确认,
避免遗漏;清单可复用为模板,下次一键生成。
### 1.1 本期范围
- 单用户使用,数据按登录用户隔离(复用现有 JWT 鉴权)。
- 移动端优先(`MobileShell` + 底部 TabBar 体系),桌面浏览器兼容可用即可。
- 数据落库 MariaDB,通过现有 Express `/api/*` 提供接口。
### 1.2 不在本期范围(明确排除)
- 多人协作 / 共享打卡 / 打卡排行榜。
- 系统通知、推送提醒、定时提醒(V0.2 不引入通知能力)。
- 复杂统计报表(周报/月报/趋势图),本期只提供完成率概览。
- 桌面端专用布局、横屏适配。
- 清单的周期性重复(如"每次出差都生成"),本期只做**临时清单 + 模板手动复用**。
---
## 2. 术语与概念定义
| 术语 | 定义 |
| --- | --- |
| **打卡项(Habit)** | 一个被跟踪的周期性目标,如"喝水 每天 8 次""健身 每周 3 次" |
| **周期类型** | `day` / `week` / `month` / `year`,决定进度统计窗口 |
| **目标次数 n** | 一个周期内需要完成的打卡次数,n ≥ 1 |
| **周期窗口** | 日=自然日;周=自然周(周一 00:00 至周日 23:59);月=自然月;年=自然年。时区统一 Asia/Shanghai |
| **打卡记录(CheckIn)** | 一次打卡动作,归属到某个**本地日历日**(可补打到过去日期) |
| **环形进度** | 卡片上的圆环组件,`进度 = min(已完成次数 / n, 1)`;达标=闭合 |
| **清单(CheckList)** | 一次性检查清单,如"出差 · 10月杭州",含若干检查项 |
| **检查项(CheckItem)** | 清单中的单条待检查事项,勾选即完成 |
| **模板(Template)** | 可被复用的清单结构(一组检查项标题),用于一键生成新清单 |
| **日期状态** | 某日的打卡完成情况:全部完成 / 部分完成 / 未开始 / 超额完成 |
---
## 3. 需求点
**编号规则**:`类型-模块-序号`。类型:F=功能需求、U=交互与体验、D=数据、N=非功能、E=异常与边界。
模块:CK=打卡、CL=日历、LS=清单、SY=通用。
**优先级**:P0 = 本期必须有;P1 = 本期应该有;P2 = 有则更好(可延后)。
### 3.1 功能需求 — 打卡(F-CK)
| 编号 | 需求描述 | 验收要点 | 优先级 |
| --- | --- | --- | --- |
| F-CK-01 | 支持新建打卡项:名称、周期类型、目标次数 n、颜色、图标、备注 | 表单校验:名称必填且 ≤ 20 字;n 为 ≥1 的整数 | P0 |
| F-CK-02 | 周期类型支持**每天 n 次 / 每周 n 次 / 每月 n 次 / 每年 n 次**四种 | 四种类型均可创建并正确统计 | P0 |
| F-CK-03 | 卡片以**环形进度**展示当前周期完成情况,环中心显示 `已完成/n` | 进度 = 已完成次数 / n,上限 100% | P0 |
| F-CK-04 | **每天 1 次**的打卡项:打卡后环形**直接闭合**(100%),卡片进入"已完成"态 | 一次点击即闭合,不出现 50% 之类的中间态 | P0 |
| F-CK-05 | **每周 n 次**(及月/年):每打卡一次环形闭合对应百分比(如 n=3,打 1 次=33.3%、2 次=66.7%、3 次=100% 闭合) | 进度随每次打卡实时刷新,带过渡动画 | P0 |
| F-CK-06 | 打卡主操作:点击卡片上的打卡按钮即完成一次打卡 | 单击即生效,响应 < 300ms(乐观更新) | P0 |
| F-CK-07 | 打卡**完成特效**:达标(进度达 100%)时播放特效——环形闭合动画 + 光晕/粒子扩散 + 轻量触感反馈 | 特效时长 ≤ 800ms,不阻塞后续操作 | P0 |
| F-CK-08 | 打卡记录需记录**发生日期**(本地日历日)与时间戳 | 支持按日回看,跨零点归属正确 | P0 |
| F-CK-09 | 支持**撤销**:可撤销本周期内最近一次打卡(主要是误触场景) | 撤销后进度回退,记录物理删除或标记撤销 | P1 |
| F-CK-10 | 支持**补打卡**:对过去日期补记一次打卡,默认允许回溯 7 天 | 补打后对应日期的状态与进度同步更新 | P1 |
| F-CK-11 | **不允许提前打卡**(未来日期不可打卡) | 明天的项只展示为"计划",按钮置灰 | P0 |
| F-CK-12 | 展示**连续完成天数/周期数(streak)** | 如"已连续 5 天",断签归零 | P1 |
| F-CK-13 | 打卡项可**编辑**(改名/改 n/改颜色/改周期)与**停用(归档)** | 归档后不出现在今日待办,历史保留 | P1 |
| F-CK-14 | 修改目标次数 n 后,**当前周期**进度按新 n 重算展示,不改写历史记录 | 例如 n 由 3 改 5,已完成 3 次 → 显示 3/5(60%) | P1 |
| F-CK-15 | **超额打卡**:周期内超过 n 仍可继续打卡,环形保持闭合,中心显示 `n+k`(如 3+1) | 不清零、不报错 | P2 |
| F-CK-16 | 打卡项支持**排序**(拖动或置顶)与分组展示 | 今日列表按"未完成在前、已完成在后"默认排序 | P2 |
| F-CK-17 | 打卡时可附带**备注**(可选,纯文本) | 列表详情中可查看 | P2 |
### 3.2 功能需求 — 日历(F-CL)
| 编号 | 需求描述 | 验收要点 | 优先级 |
| --- | --- | --- | --- |
| F-CL-01 | 提供**月历视图**:当月日历网格,可切换上/下月,点击日期查看当日详情 | 周一起始,周一~周日七列 | P0 |
| F-CL-02 | **今天**视图(默认):列出今天应打卡的项及完成状态,可直接打卡 | 打开页面默认定位今天 | P0 |
| F-CL-03 | **昨天**视图:展示昨天各打卡项的**已完成结果**(完成/未完成/超额),未完成项可进行补打 | 以回看为主,标记为"已完成的历史" | P0 |
| F-CL-04 | **明天**视图:展示**明天计划要打卡的项**(按周期规则推算),只读,不可打卡 | 明确标注"计划",打卡按钮不可用 | P0 |
| F-CL-05 | 今天/昨天/明天**快捷切换**(顶部 Segmented 或左右滑动) | 一键切换,切换有过渡动画 | P0 |
| F-CL-06 | **日期标记**:月历中每个日期按完成情况标记——全部完成(实心)、部分完成(半填充)、未开始(空心/无标记)、超额完成(特殊标记) | 一眼能看出当月坚持情况 | P0 |
| F-CL-07 | **日期详情**:点击某日期,展示当天每个打卡项的"目标次数 / 已完成次数 / 状态" | 过去日期可补打,未来日期只读 | P0 |
| F-CL-08 | 日历与打卡**数据同源**:打卡操作在日历视图与打卡列表视图中即时一致 | 任一处打卡,另一处刷新 | P0 |
| F-CL-09 | 月历顶部展示**当月完成率**(完成次数 / 应完成次数) | 百分比展示 | P1 |
| F-CL-10 | 支持左右**滑动手势**切换月份 | 与按钮切换等价 | P2 |
| F-CL-11 | 月视图支持简单的**完成热力**(按完成率深浅着色) | 可选展示,默认关闭 | P2 |
### 3.3 功能需求 — 清单(F-LS)
| 编号 | 需求描述 | 验收要点 | 优先级 |
| --- | --- | --- | --- |
| F-LS-01 | 支持新建**检查清单**:名称、场景标签(出差 / 回老家 / 自定义)、备注 | 名称必填,场景标签可空 | P0 |
| F-LS-02 | 清单内可**增删改检查项**,支持排序 | 单条 ≤ 40 字 | P0 |
| F-LS-03 | **勾选完成**:点击检查项前的复选框即打勾,已完成项置灰 + 删除线;可取消勾选 | 单击切换,状态即时保存 | P0 |
| F-LS-04 | 清单头部显示**进度**:`已完成 / 总数` + 进度条;全部完成时显示完成态 | 全部勾完触发完成提示 | P0 |
| F-LS-05 | 清单**全部完成**时播放完成特效(与 F-CK-07 同一套特效组件) | 复用,风格统一 | P1 |
| F-LS-06 | **模板**:系统预置常用模板(出差、回老家),一键基于模板生成新清单 | 至少 2 个预置模板 | P1 |
| F-LS-07 | **另存为模板**:用户可将自己的清单保存为自定义模板,后续复用 | 自定义模板可删除 | P1 |
| F-LS-08 | **复用历史清单**:从历史清单复制生成一份新清单(勾选状态重置) | 原清单保持不变 | P1 |
| F-LS-09 | 清单**归档/删除**:完成的清单可归档(不出现在进行中列表),可查看历史 | 删除需二次确认 | P1 |
| F-LS-10 | 清单项支持**备注/数量**说明(如"充电器 ×1") | 展示在标题下方,弱色小字 | P2 |
| F-LS-11 | 清单支持**一键全部勾选 / 全部取消** | 批量操作入口 | P2 |
### 3.4 功能需求 — 通用(F-SY)
| 编号 | 需求描述 | 验收要点 | 优先级 |
| --- | --- | --- | --- |
| F-SY-01 | 底部 TabBar 新增**「打卡」页** 和 **「清单」页** | 图标风格与现有 TabIcon 一致 | P0 |
| F-SY-02 | 「打卡」页内集成 打卡/日历 两个功能,日历放在上半截,默认显示一行为周视图,下拉显示为月视图。 | 日历中选择时间与下部打卡任务联动 | P0 |
| F-SY-03 | 所有接口复用现有鉴权(`authRequired`,JWT),数据按 `userId` 隔离 | 未登录跳转 `/login` | P0 |
| F-SY-04 | 后端新增 REST 路由(建议 `/api/checkin/*`、`/api/checklist/*`),挂载到 `server/index.js` | 与现有路由风格一致 | P0 |
| F-SY-05 | 时间处理统一 **Asia/Shanghai**,前端与后端对"日历日"的判定一致 | 跨零点打卡归属当天 | P0 |
| F-SY-06 | 打卡/勾选操作采用**乐观更新**,接口失败时回滚并 Toast 提示 | 失败可见、状态不失真 | P1 |
| F-SY-07 | 写接口**幂等/防抖**:同一打卡项 1 秒内重复点击只记一次 | 前端防抖 + 后端唯一约束双保险 | P1 |
| F-SY-08 | 页面可见性变化时刷新数据(复用 `useRefreshOnVisible`) | 从后台切回自动同步 | P1 |
### 3.5 交互与体验需求(U)
| 编号 | 需求描述 | 验收要点 | 优先级 |
| --- | --- | --- | --- |
| U-01 | **环形进度组件**:SVG 圆环,背景环 + 进度环,进度变化带 400ms ease-out 动画 | 组件可复用(打卡卡片、清单进度共用) | P0 |
| U-02 | **完成特效**:达标瞬间环形闭合 + 光晕/粒子扩散,约 600ms;可重复观看不烦扰 | 不阻塞操作,动画结束自动清理 | P0 |
| U-03 | **触感反馈**:完成打卡时轻量震动(Web Vibration API / Tauri),可在设置中关闭 | 默认开启,关闭后仅视觉反馈 | P1 |
| U-04 | 移动端适配:375~430px 视口,复用 `globals.css` 的 `.card` 体系,安全区与底部 TabBar 不遮挡内容 | 真机无横向滚动、无遮挡 | P0 |
| U-05 | 空态 / 加载态 / 错误态三态齐全(首次使用引导"创建第一个打卡项") | 空态有明确 CTA | P0 |
| U-06 | 打卡按钮足够大(点击热区 ≥ 44×44),符合移动端触控规范 | 误触率低 | P1 |
| U-07 | 动效**性能降级**:低端设备或 `prefers-reduced-motion` 时关闭粒子特效,仅保留进度动画 | 不卡顿 | P1 |
| U-08 | 深浅色:跟随现有浅色主题,卡片/分割线风格与首页、监控页一致 | 视觉统一 | P1 |
| U-09 | 清单勾选有明确的勾选动画(勾形描边动画) | 反馈即时 | P2 |
### 3.6 数据需求(D)
| 编号 | 需求描述 | 验收要点 | 优先级 |
| --- | --- | --- | --- |
| D-01 | 新增 Prisma 模型:`Habit`(打卡项)、`CheckIn`(打卡记录) | 字段见附录 A | P0 |
| D-02 | 新增 Prisma 模型:`CheckList`(清单)、`CheckListItem`(检查项)、`CheckListTemplate`(模板,P1) | 字段见附录 A | P0 |
| D-03 | `Habit` / `CheckList` 均带 `userId` 外键,删除用户级联删除 | 数据隔离 | P0 |
| D-04 | `CheckIn` 建立 `(habitId, date)` 索引,支持按日期范围查询 | 日历月查询走索引 | P0 |
| D-05 | 提供迁移 SQL 并纳入 `prisma migrate deploy` 流程 | 与 V0.1 迁移流程一致 | P0 |
| D-06 | 软删除语义:`Habit.archived`、`CheckList.archived`,历史记录保留 | 不物理删除历史 | P1 |
### 3.7 非功能需求(N)
| 编号 | 需求描述 | 验收要点 | 优先级 |
| --- | --- | --- | --- |
| N-01 | 页面首屏数据加载 < 1.5s(内网/正常网络),交互响应 < 300ms(乐观更新) | 真机可感知流畅 | P0 |
| N-02 | 兼容 Android WebView(Tauri 打包)与移动端浏览器,不依赖桌面专有 API | 真机验证 | P0 |
| N-03 | 单用户数据量按"千级记录/年"设计,无需分表;月查询一次请求返回 | 日历切月无等待感 | P1 |
| N-04 | 所有接口 `authRequired`,越权访问返回 401 | 安全基线 | P0 |
| N-05 | 前端代码组织遵循现有约定:`src/pages/checkin/*` + `src/components/*` + `server/routes/*` | 结构一致 | P0 |
### 3.8 异常与边界需求(E)
| 编号 | 需求描述 | 处理约定 | 优先级 |
| --- | --- | --- | --- |
| E-01 | 重复点击打卡 | 前端防抖 + 后端唯一约束,只记一次 | P0 |
| E-02 | 接口失败 / 断网 | 乐观更新回滚 + Toast 提示,可重试 | P0 |
| E-03 | **周期中途新建**打卡项(如周三创建"每周 3 次") | 本期按**完整目标 n** 计算,不按剩余天数折算 | P1 |
| E-04 | 每月 n 次在天数不同的月份 | 按**自然月**窗口,不折算 | P0 |
| E-05 | 每年 n 次跨年 | 按**自然年**窗口,年末清零 | P0 |
| E-06 | 删除打卡项 | 归档(软删除)为主;硬删除需二次确认并提示"历史记录将一并删除" | P1 |
| E-07 | 清单项全部删除后清单进度为 0/0 | 显示空清单,不显示 100% | P1 |
| E-08 | 跨零点打卡(23:59 → 00:00) | 以**提交时刻的本地日历日**归属 | P0 |
---
## 4. 优先级汇总(本期交付范围)
- **P0(必须)**:F-CK-01~08、F-CK-11;F-CL-01~08;F-LS-01~04;F-SY-01~05;U-01、U-02、U-04、U-05;D-01~05;N-01、N-02、N-04、N-05;E-01、E-02、E-04、E-05、E-08。
- **P1(应该)**:F-CK-09、10、12~14;F-CL-09;F-LS-05~09;F-SY-06~08;U-03、U-06~08;D-06;N-03;E-03、E-06、E-07。
- **P2(可选/延后)**:F-CK-15~17;F-CL-10、11;F-LS-10、11;U-09。
---
## 5. 里程碑建议
| 里程碑 | 内容 |
| --- | --- |
| **M1 数据与接口** | Prisma 模型 + 迁移;`server/routes/checkin.js`、`checklist.js`;周期进度计算与日期查询 |
| **M2 打卡视图** | 环形进度组件 + 打卡卡片列表 + 打卡/撤销/补打 + 完成特效 |
| **M3 日历视图** | 月历网格 + 今天/昨天/明天三视图 + 日期标记 + 日期详情 |
| **M4 清单视图** | 清单 CRUD + 勾选交互 + 进度条 + 模板与复用 |
| **M5 联调打磨** | 真机验证、动效降级、空态/错误态、性能与跨天边界 |
---
## 6. 待确认问题(评审时决策)
| # | 问题 | 建议 |
| --- | --- | --- |
| 1 | 是否新增第 5 个底部 Tab「打卡」,还是放在首页入口? | 建议新增 Tab,入口最清晰 |
| 2 | 周起始日是周一还是周日? | 建议周一(国内习惯) |
| 3 | 是否允许"明天提前打卡"? | 建议**不允许**(F-CK-11),保持统计口径干净 |
| 4 | 删除打卡项时历史是否保留? | 建议默认归档保留,硬删除才清历史 |
| 5 | 预置模板具体包含哪些项? | 评审时列出"出差/回老家"各 8~12 条初始项 |
| 6 | 完成特效是否可全局关闭? | 建议在"我"页提供开关(默认开) |
---
## 附录 A:核心实体草案(供设计阶段参考,非最终表结构)
```prisma
// 打卡项
model Habit {
id Int @id @default(autoincrement())
userId Int
name String
period String // day | week | month | year
target Int @default(1) // 周期内目标次数 n
color String @default("#1677ff")
icon String @default("")
note String @default("")
archived Boolean @default(false)
sortOrder Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
records CheckIn[]
@@index([userId, archived])
}
// 打卡记录(一次打卡 = 一条;同日多次为多条)
model CheckIn {
id Int @id @default(autoincrement())
habitId Int
habit Habit @relation(fields: [habitId], references: [id], onDelete: Cascade)
userId Int
date String // 本地日历日 "YYYY-MM-DD" (Asia/Shanghai)
seq Int @default(1) // 当日第几次 (1..n, 超额继续累加)
note String @default("")
source String @default("user") // user | backfill(补打)
createdAt DateTime @default(now())
@@unique([habitId, date, seq])
@@index([userId, date])
@@index([habitId, date])
}
// 检查清单
model CheckList {
id Int @id @default(autoincrement())
userId Int
title String
scene String @default("") // 出差 | 回老家 | 自定义
note String @default("")
archived Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
items CheckListItem[]
@@index([userId, archived])
}
// 检查项
model CheckListItem {
id Int @id @default(autoincrement())
listId Int
list CheckList @relation(fields: [listId], references: [id], onDelete: Cascade)
title String
note String @default("")
done Boolean @default(false)
doneAt DateTime?
sortOrder Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([listId, sortOrder])
}
// 清单模板(P1)
model CheckListTemplate {
id Int @id @default(autoincrement())
userId Int? // null = 系统预置
name String
scene String @default("")
items Json // [{ title, note }]
builtin Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
```
> 说明:周期进度由 `CheckIn` 按周期窗口现场聚合(不冗余计数字段),避免并发与修改 n 后的一致性问题。
---
## 附录 B:实现约定(开发期补充,用于消除口径歧义)
| # | 约定 | 说明 |
| --- | --- | --- |
| B-1 | **周期窗口** | 日=自然日;周=周一 00:00 ~ 周日 23:59;月=自然月;年=自然年;时区 Asia/Shanghai |
| B-2 | **环形进度** | 周期完成度 = 周期内累计打卡次数 / n(每周 n 次:打 1 次=1/n 环;每天 1 次:打 1 次直接闭合) |
| B-3 | **日历日期标记** | 用"当日完成度":当日次数 / **日均期望次数**(每天 n 次→n;每周 n 次→ceil(n/7);每月→ceil(n/当月天数);每年→ceil(n/365))。全完成=实心绿点,部分=黄点,超额=紫点,无记录=不显示 |
| B-4 | **streak** | 从当前周期往回数连续达标的周期数;**当前周期未结束且未达标不算断签**(跳过继续往前数) |
| B-5 | **补打限制** | 只能补打最近 7 天(`BACKFILL_DAYS`),未来日期禁止打卡 |
| B-6 | **撤销语义** | 撤销删除本周期内"最近一次"打卡(按 date desc、seq desc 取第一条) |
| B-7 | **删除打卡项** | 默认归档(`archived=true`,历史保留);`?hard=1` 才物理删除并清记录 |
| B-8 | **接口前缀** | 打卡 `/api/checkin/*`、清单 `/api/checklists/*`,全部 `authRequired`,数据按 `userId` 隔离 |
| B-9 | **前端入口** | 底部新增第 5 个 Tab「打卡」→ `/checkin`,页内三视图:打卡 / 日历 / 清单 |
| B-10 | **特效开关** | 完成特效 + 触感反馈受 `localStorage iboard_celebrate` 控制(默认开),系统开启"减弱动效"时自动降级 |
### 接口清单
| 方法 | 路径 | 说明 |
| --- | --- | --- |
| GET | `/api/checkin/habits?date=` | 打卡项列表 + 当前周期进度 + streak |
| POST | `/api/checkin/habits` | 新建打卡项 |
| PUT | `/api/checkin/habits/:id` | 编辑(改 n / 周期 / 归档) |
| DELETE | `/api/checkin/habits/:id?hard=1` | 归档(默认)/ 物理删除 |
| POST | `/api/checkin/habits/:id/check` | 打卡一次(可带 `date` 补打、`note`) |
| DELETE | `/api/checkin/habits/:id/check?date=` | 撤销本周期最近一次 |
| GET | `/api/checkin/day?date=` | 某日详情(今天可打卡 / 昨天可补打 / 明天只读) |
| GET | `/api/checkin/calendar?month=YYYY-MM` | 月历:每日状态 + 当月完成率 |
| GET | `/api/checklists?archived=0\|1` | 清单列表(含进度) |
| POST | `/api/checklists` | 新建清单(可带 `templateId` 从模板生成) |
| GET/PUT/DELETE | `/api/checklists/:id` | 详情 / 编辑 / 删除 |
| POST | `/api/checklists/:id/duplicate` | 复制为新清单(勾选重置) |
| POST | `/api/checklists/:id/items` | 新增检查项 |
| PUT/DELETE | `/api/checklists/items/:id` | 改标题 / 勾选(`done`) / 删除 |
| POST | `/api/checklists/:id/check-all` | 一键全选 / 全不选 |
| GET/POST | `/api/checklists/templates` | 模板列表(含系统预置)/ 另存为模板 |
| DELETE | `/api/checklists/templates/:id` | 删除自定义模板 |
+25
View File
@@ -107,3 +107,28 @@ export type MihomeEnvSample = Prisma.MihomeEnvSampleModel
*
*/
export type MihomeEnvHour = Prisma.MihomeEnvHourModel
/**
* Model Habit
*
*/
export type Habit = Prisma.HabitModel
/**
* Model CheckIn
*
*/
export type CheckIn = Prisma.CheckInModel
/**
* Model CheckList
*
*/
export type CheckList = Prisma.CheckListModel
/**
* Model CheckListItem
*
*/
export type CheckListItem = Prisma.CheckListItemModel
/**
* Model CheckListTemplate
*
*/
export type CheckListTemplate = Prisma.CheckListTemplateModel
+25
View File
@@ -131,3 +131,28 @@ export type MihomeEnvSample = Prisma.MihomeEnvSampleModel
*
*/
export type MihomeEnvHour = Prisma.MihomeEnvHourModel
/**
* Model Habit
*
*/
export type Habit = Prisma.HabitModel
/**
* Model CheckIn
*
*/
export type CheckIn = Prisma.CheckInModel
/**
* Model CheckList
*
*/
export type CheckList = Prisma.CheckListModel
/**
* Model CheckListItem
*
*/
export type CheckListItem = Prisma.CheckListItemModel
/**
* Model CheckListTemplate
*
*/
export type CheckListTemplate = Prisma.CheckListTemplateModel
File diff suppressed because one or more lines are too long
+462 -2
View File
@@ -401,7 +401,12 @@ export const ModelName = {
MihomeHome: 'MihomeHome',
MihomeDevice: 'MihomeDevice',
MihomeEnvSample: 'MihomeEnvSample',
MihomeEnvHour: 'MihomeEnvHour'
MihomeEnvHour: 'MihomeEnvHour',
Habit: 'Habit',
CheckIn: 'CheckIn',
CheckList: 'CheckList',
CheckListItem: 'CheckListItem',
CheckListTemplate: 'CheckListTemplate'
} as const
export type ModelName = (typeof ModelName)[keyof typeof ModelName]
@@ -417,7 +422,7 @@ export type TypeMap<ExtArgs extends runtime.Types.Extensions.InternalArgs = runt
omit: GlobalOmitOptions
}
meta: {
modelProps: "user" | "article" | "strategy" | "indicator" | "indicatorPoint" | "news" | "alert" | "serverNode" | "serverNodeHistory" | "serviceCheck" | "econ_SowInventory" | "scraperCache" | "oilPriceHistory" | "mihomeAccount" | "mihomeHome" | "mihomeDevice" | "mihomeEnvSample" | "mihomeEnvHour"
modelProps: "user" | "article" | "strategy" | "indicator" | "indicatorPoint" | "news" | "alert" | "serverNode" | "serverNodeHistory" | "serviceCheck" | "econ_SowInventory" | "scraperCache" | "oilPriceHistory" | "mihomeAccount" | "mihomeHome" | "mihomeDevice" | "mihomeEnvSample" | "mihomeEnvHour" | "habit" | "checkIn" | "checkList" | "checkListItem" | "checkListTemplate"
txIsolationLevel: TransactionIsolationLevel
}
model: {
@@ -1609,6 +1614,336 @@ export type TypeMap<ExtArgs extends runtime.Types.Extensions.InternalArgs = runt
}
}
}
Habit: {
payload: Prisma.$HabitPayload<ExtArgs>
fields: Prisma.HabitFieldRefs
operations: {
findUnique: {
args: Prisma.HabitFindUniqueArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$HabitPayload> | null
}
findUniqueOrThrow: {
args: Prisma.HabitFindUniqueOrThrowArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$HabitPayload>
}
findFirst: {
args: Prisma.HabitFindFirstArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$HabitPayload> | null
}
findFirstOrThrow: {
args: Prisma.HabitFindFirstOrThrowArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$HabitPayload>
}
findMany: {
args: Prisma.HabitFindManyArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$HabitPayload>[]
}
create: {
args: Prisma.HabitCreateArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$HabitPayload>
}
createMany: {
args: Prisma.HabitCreateManyArgs<ExtArgs>
result: BatchPayload
}
delete: {
args: Prisma.HabitDeleteArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$HabitPayload>
}
update: {
args: Prisma.HabitUpdateArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$HabitPayload>
}
deleteMany: {
args: Prisma.HabitDeleteManyArgs<ExtArgs>
result: BatchPayload
}
updateMany: {
args: Prisma.HabitUpdateManyArgs<ExtArgs>
result: BatchPayload
}
upsert: {
args: Prisma.HabitUpsertArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$HabitPayload>
}
aggregate: {
args: Prisma.HabitAggregateArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.AggregateHabit>
}
groupBy: {
args: Prisma.HabitGroupByArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.HabitGroupByOutputType>[]
}
count: {
args: Prisma.HabitCountArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.HabitCountAggregateOutputType> | number
}
}
}
CheckIn: {
payload: Prisma.$CheckInPayload<ExtArgs>
fields: Prisma.CheckInFieldRefs
operations: {
findUnique: {
args: Prisma.CheckInFindUniqueArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$CheckInPayload> | null
}
findUniqueOrThrow: {
args: Prisma.CheckInFindUniqueOrThrowArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$CheckInPayload>
}
findFirst: {
args: Prisma.CheckInFindFirstArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$CheckInPayload> | null
}
findFirstOrThrow: {
args: Prisma.CheckInFindFirstOrThrowArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$CheckInPayload>
}
findMany: {
args: Prisma.CheckInFindManyArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$CheckInPayload>[]
}
create: {
args: Prisma.CheckInCreateArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$CheckInPayload>
}
createMany: {
args: Prisma.CheckInCreateManyArgs<ExtArgs>
result: BatchPayload
}
delete: {
args: Prisma.CheckInDeleteArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$CheckInPayload>
}
update: {
args: Prisma.CheckInUpdateArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$CheckInPayload>
}
deleteMany: {
args: Prisma.CheckInDeleteManyArgs<ExtArgs>
result: BatchPayload
}
updateMany: {
args: Prisma.CheckInUpdateManyArgs<ExtArgs>
result: BatchPayload
}
upsert: {
args: Prisma.CheckInUpsertArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$CheckInPayload>
}
aggregate: {
args: Prisma.CheckInAggregateArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.AggregateCheckIn>
}
groupBy: {
args: Prisma.CheckInGroupByArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.CheckInGroupByOutputType>[]
}
count: {
args: Prisma.CheckInCountArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.CheckInCountAggregateOutputType> | number
}
}
}
CheckList: {
payload: Prisma.$CheckListPayload<ExtArgs>
fields: Prisma.CheckListFieldRefs
operations: {
findUnique: {
args: Prisma.CheckListFindUniqueArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$CheckListPayload> | null
}
findUniqueOrThrow: {
args: Prisma.CheckListFindUniqueOrThrowArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$CheckListPayload>
}
findFirst: {
args: Prisma.CheckListFindFirstArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$CheckListPayload> | null
}
findFirstOrThrow: {
args: Prisma.CheckListFindFirstOrThrowArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$CheckListPayload>
}
findMany: {
args: Prisma.CheckListFindManyArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$CheckListPayload>[]
}
create: {
args: Prisma.CheckListCreateArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$CheckListPayload>
}
createMany: {
args: Prisma.CheckListCreateManyArgs<ExtArgs>
result: BatchPayload
}
delete: {
args: Prisma.CheckListDeleteArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$CheckListPayload>
}
update: {
args: Prisma.CheckListUpdateArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$CheckListPayload>
}
deleteMany: {
args: Prisma.CheckListDeleteManyArgs<ExtArgs>
result: BatchPayload
}
updateMany: {
args: Prisma.CheckListUpdateManyArgs<ExtArgs>
result: BatchPayload
}
upsert: {
args: Prisma.CheckListUpsertArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$CheckListPayload>
}
aggregate: {
args: Prisma.CheckListAggregateArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.AggregateCheckList>
}
groupBy: {
args: Prisma.CheckListGroupByArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.CheckListGroupByOutputType>[]
}
count: {
args: Prisma.CheckListCountArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.CheckListCountAggregateOutputType> | number
}
}
}
CheckListItem: {
payload: Prisma.$CheckListItemPayload<ExtArgs>
fields: Prisma.CheckListItemFieldRefs
operations: {
findUnique: {
args: Prisma.CheckListItemFindUniqueArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$CheckListItemPayload> | null
}
findUniqueOrThrow: {
args: Prisma.CheckListItemFindUniqueOrThrowArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$CheckListItemPayload>
}
findFirst: {
args: Prisma.CheckListItemFindFirstArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$CheckListItemPayload> | null
}
findFirstOrThrow: {
args: Prisma.CheckListItemFindFirstOrThrowArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$CheckListItemPayload>
}
findMany: {
args: Prisma.CheckListItemFindManyArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$CheckListItemPayload>[]
}
create: {
args: Prisma.CheckListItemCreateArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$CheckListItemPayload>
}
createMany: {
args: Prisma.CheckListItemCreateManyArgs<ExtArgs>
result: BatchPayload
}
delete: {
args: Prisma.CheckListItemDeleteArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$CheckListItemPayload>
}
update: {
args: Prisma.CheckListItemUpdateArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$CheckListItemPayload>
}
deleteMany: {
args: Prisma.CheckListItemDeleteManyArgs<ExtArgs>
result: BatchPayload
}
updateMany: {
args: Prisma.CheckListItemUpdateManyArgs<ExtArgs>
result: BatchPayload
}
upsert: {
args: Prisma.CheckListItemUpsertArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$CheckListItemPayload>
}
aggregate: {
args: Prisma.CheckListItemAggregateArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.AggregateCheckListItem>
}
groupBy: {
args: Prisma.CheckListItemGroupByArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.CheckListItemGroupByOutputType>[]
}
count: {
args: Prisma.CheckListItemCountArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.CheckListItemCountAggregateOutputType> | number
}
}
}
CheckListTemplate: {
payload: Prisma.$CheckListTemplatePayload<ExtArgs>
fields: Prisma.CheckListTemplateFieldRefs
operations: {
findUnique: {
args: Prisma.CheckListTemplateFindUniqueArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$CheckListTemplatePayload> | null
}
findUniqueOrThrow: {
args: Prisma.CheckListTemplateFindUniqueOrThrowArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$CheckListTemplatePayload>
}
findFirst: {
args: Prisma.CheckListTemplateFindFirstArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$CheckListTemplatePayload> | null
}
findFirstOrThrow: {
args: Prisma.CheckListTemplateFindFirstOrThrowArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$CheckListTemplatePayload>
}
findMany: {
args: Prisma.CheckListTemplateFindManyArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$CheckListTemplatePayload>[]
}
create: {
args: Prisma.CheckListTemplateCreateArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$CheckListTemplatePayload>
}
createMany: {
args: Prisma.CheckListTemplateCreateManyArgs<ExtArgs>
result: BatchPayload
}
delete: {
args: Prisma.CheckListTemplateDeleteArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$CheckListTemplatePayload>
}
update: {
args: Prisma.CheckListTemplateUpdateArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$CheckListTemplatePayload>
}
deleteMany: {
args: Prisma.CheckListTemplateDeleteManyArgs<ExtArgs>
result: BatchPayload
}
updateMany: {
args: Prisma.CheckListTemplateUpdateManyArgs<ExtArgs>
result: BatchPayload
}
upsert: {
args: Prisma.CheckListTemplateUpsertArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$CheckListTemplatePayload>
}
aggregate: {
args: Prisma.CheckListTemplateAggregateArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.AggregateCheckListTemplate>
}
groupBy: {
args: Prisma.CheckListTemplateGroupByArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.CheckListTemplateGroupByOutputType>[]
}
count: {
args: Prisma.CheckListTemplateCountArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.CheckListTemplateCountAggregateOutputType> | number
}
}
}
}
} & {
other: {
@@ -1924,6 +2259,81 @@ export const MihomeEnvHourScalarFieldEnum = {
export type MihomeEnvHourScalarFieldEnum = (typeof MihomeEnvHourScalarFieldEnum)[keyof typeof MihomeEnvHourScalarFieldEnum]
export const HabitScalarFieldEnum = {
id: 'id',
userId: 'userId',
name: 'name',
period: 'period',
target: 'target',
color: 'color',
icon: 'icon',
note: 'note',
archived: 'archived',
sortOrder: 'sortOrder',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
} as const
export type HabitScalarFieldEnum = (typeof HabitScalarFieldEnum)[keyof typeof HabitScalarFieldEnum]
export const CheckInScalarFieldEnum = {
id: 'id',
habitId: 'habitId',
userId: 'userId',
date: 'date',
seq: 'seq',
note: 'note',
source: 'source',
createdAt: 'createdAt'
} as const
export type CheckInScalarFieldEnum = (typeof CheckInScalarFieldEnum)[keyof typeof CheckInScalarFieldEnum]
export const CheckListScalarFieldEnum = {
id: 'id',
userId: 'userId',
title: 'title',
scene: 'scene',
note: 'note',
archived: 'archived',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
} as const
export type CheckListScalarFieldEnum = (typeof CheckListScalarFieldEnum)[keyof typeof CheckListScalarFieldEnum]
export const CheckListItemScalarFieldEnum = {
id: 'id',
listId: 'listId',
title: 'title',
note: 'note',
done: 'done',
doneAt: 'doneAt',
sortOrder: 'sortOrder',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
} as const
export type CheckListItemScalarFieldEnum = (typeof CheckListItemScalarFieldEnum)[keyof typeof CheckListItemScalarFieldEnum]
export const CheckListTemplateScalarFieldEnum = {
id: 'id',
userId: 'userId',
name: 'name',
scene: 'scene',
items: 'items',
builtin: 'builtin',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
} as const
export type CheckListTemplateScalarFieldEnum = (typeof CheckListTemplateScalarFieldEnum)[keyof typeof CheckListTemplateScalarFieldEnum]
export const SortOrder = {
asc: 'asc',
desc: 'desc'
@@ -2128,6 +2538,51 @@ export const MihomeEnvSampleOrderByRelevanceFieldEnum = {
export type MihomeEnvSampleOrderByRelevanceFieldEnum = (typeof MihomeEnvSampleOrderByRelevanceFieldEnum)[keyof typeof MihomeEnvSampleOrderByRelevanceFieldEnum]
export const HabitOrderByRelevanceFieldEnum = {
name: 'name',
period: 'period',
color: 'color',
icon: 'icon',
note: 'note'
} as const
export type HabitOrderByRelevanceFieldEnum = (typeof HabitOrderByRelevanceFieldEnum)[keyof typeof HabitOrderByRelevanceFieldEnum]
export const CheckInOrderByRelevanceFieldEnum = {
date: 'date',
note: 'note',
source: 'source'
} as const
export type CheckInOrderByRelevanceFieldEnum = (typeof CheckInOrderByRelevanceFieldEnum)[keyof typeof CheckInOrderByRelevanceFieldEnum]
export const CheckListOrderByRelevanceFieldEnum = {
title: 'title',
scene: 'scene',
note: 'note'
} as const
export type CheckListOrderByRelevanceFieldEnum = (typeof CheckListOrderByRelevanceFieldEnum)[keyof typeof CheckListOrderByRelevanceFieldEnum]
export const CheckListItemOrderByRelevanceFieldEnum = {
title: 'title',
note: 'note'
} as const
export type CheckListItemOrderByRelevanceFieldEnum = (typeof CheckListItemOrderByRelevanceFieldEnum)[keyof typeof CheckListItemOrderByRelevanceFieldEnum]
export const CheckListTemplateOrderByRelevanceFieldEnum = {
name: 'name',
scene: 'scene'
} as const
export type CheckListTemplateOrderByRelevanceFieldEnum = (typeof CheckListTemplateOrderByRelevanceFieldEnum)[keyof typeof CheckListTemplateOrderByRelevanceFieldEnum]
/**
* Field references
@@ -2302,6 +2757,11 @@ export type GlobalOmitConfig = {
mihomeDevice?: Prisma.MihomeDeviceOmit
mihomeEnvSample?: Prisma.MihomeEnvSampleOmit
mihomeEnvHour?: Prisma.MihomeEnvHourOmit
habit?: Prisma.HabitOmit
checkIn?: Prisma.CheckInOmit
checkList?: Prisma.CheckListOmit
checkListItem?: Prisma.CheckListItemOmit
checkListTemplate?: Prisma.CheckListTemplateOmit
}
/* Types for Logging */
@@ -68,7 +68,12 @@ export const ModelName = {
MihomeHome: 'MihomeHome',
MihomeDevice: 'MihomeDevice',
MihomeEnvSample: 'MihomeEnvSample',
MihomeEnvHour: 'MihomeEnvHour'
MihomeEnvHour: 'MihomeEnvHour',
Habit: 'Habit',
CheckIn: 'CheckIn',
CheckList: 'CheckList',
CheckListItem: 'CheckListItem',
CheckListTemplate: 'CheckListTemplate'
} as const
export type ModelName = (typeof ModelName)[keyof typeof ModelName]
@@ -363,6 +368,81 @@ export const MihomeEnvHourScalarFieldEnum = {
export type MihomeEnvHourScalarFieldEnum = (typeof MihomeEnvHourScalarFieldEnum)[keyof typeof MihomeEnvHourScalarFieldEnum]
export const HabitScalarFieldEnum = {
id: 'id',
userId: 'userId',
name: 'name',
period: 'period',
target: 'target',
color: 'color',
icon: 'icon',
note: 'note',
archived: 'archived',
sortOrder: 'sortOrder',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
} as const
export type HabitScalarFieldEnum = (typeof HabitScalarFieldEnum)[keyof typeof HabitScalarFieldEnum]
export const CheckInScalarFieldEnum = {
id: 'id',
habitId: 'habitId',
userId: 'userId',
date: 'date',
seq: 'seq',
note: 'note',
source: 'source',
createdAt: 'createdAt'
} as const
export type CheckInScalarFieldEnum = (typeof CheckInScalarFieldEnum)[keyof typeof CheckInScalarFieldEnum]
export const CheckListScalarFieldEnum = {
id: 'id',
userId: 'userId',
title: 'title',
scene: 'scene',
note: 'note',
archived: 'archived',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
} as const
export type CheckListScalarFieldEnum = (typeof CheckListScalarFieldEnum)[keyof typeof CheckListScalarFieldEnum]
export const CheckListItemScalarFieldEnum = {
id: 'id',
listId: 'listId',
title: 'title',
note: 'note',
done: 'done',
doneAt: 'doneAt',
sortOrder: 'sortOrder',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
} as const
export type CheckListItemScalarFieldEnum = (typeof CheckListItemScalarFieldEnum)[keyof typeof CheckListItemScalarFieldEnum]
export const CheckListTemplateScalarFieldEnum = {
id: 'id',
userId: 'userId',
name: 'name',
scene: 'scene',
items: 'items',
builtin: 'builtin',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
} as const
export type CheckListTemplateScalarFieldEnum = (typeof CheckListTemplateScalarFieldEnum)[keyof typeof CheckListTemplateScalarFieldEnum]
export const SortOrder = {
asc: 'asc',
desc: 'desc'
@@ -566,3 +646,48 @@ export const MihomeEnvSampleOrderByRelevanceFieldEnum = {
export type MihomeEnvSampleOrderByRelevanceFieldEnum = (typeof MihomeEnvSampleOrderByRelevanceFieldEnum)[keyof typeof MihomeEnvSampleOrderByRelevanceFieldEnum]
export const HabitOrderByRelevanceFieldEnum = {
name: 'name',
period: 'period',
color: 'color',
icon: 'icon',
note: 'note'
} as const
export type HabitOrderByRelevanceFieldEnum = (typeof HabitOrderByRelevanceFieldEnum)[keyof typeof HabitOrderByRelevanceFieldEnum]
export const CheckInOrderByRelevanceFieldEnum = {
date: 'date',
note: 'note',
source: 'source'
} as const
export type CheckInOrderByRelevanceFieldEnum = (typeof CheckInOrderByRelevanceFieldEnum)[keyof typeof CheckInOrderByRelevanceFieldEnum]
export const CheckListOrderByRelevanceFieldEnum = {
title: 'title',
scene: 'scene',
note: 'note'
} as const
export type CheckListOrderByRelevanceFieldEnum = (typeof CheckListOrderByRelevanceFieldEnum)[keyof typeof CheckListOrderByRelevanceFieldEnum]
export const CheckListItemOrderByRelevanceFieldEnum = {
title: 'title',
note: 'note'
} as const
export type CheckListItemOrderByRelevanceFieldEnum = (typeof CheckListItemOrderByRelevanceFieldEnum)[keyof typeof CheckListItemOrderByRelevanceFieldEnum]
export const CheckListTemplateOrderByRelevanceFieldEnum = {
name: 'name',
scene: 'scene'
} as const
export type CheckListTemplateOrderByRelevanceFieldEnum = (typeof CheckListTemplateOrderByRelevanceFieldEnum)[keyof typeof CheckListTemplateOrderByRelevanceFieldEnum]
+5
View File
@@ -26,4 +26,9 @@ export type * from './models/MihomeHome.ts'
export type * from './models/MihomeDevice.ts'
export type * from './models/MihomeEnvSample.ts'
export type * from './models/MihomeEnvHour.ts'
export type * from './models/Habit.ts'
export type * from './models/CheckIn.ts'
export type * from './models/CheckList.ts'
export type * from './models/CheckListItem.ts'
export type * from './models/CheckListTemplate.ts'
export type * from './commonInputTypes.ts'
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+558 -1
View File
@@ -224,6 +224,10 @@ export type UserWhereInput = {
password?: Prisma.StringFilter<"User"> | string
createdAt?: Prisma.DateTimeFilter<"User"> | Date | string
updatedAt?: Prisma.DateTimeFilter<"User"> | Date | string
habits?: Prisma.HabitListRelationFilter
checkIns?: Prisma.CheckInListRelationFilter
checkLists?: Prisma.CheckListListRelationFilter
templates?: Prisma.CheckListTemplateListRelationFilter
}
export type UserOrderByWithRelationInput = {
@@ -233,6 +237,10 @@ export type UserOrderByWithRelationInput = {
password?: Prisma.SortOrder
createdAt?: Prisma.SortOrder
updatedAt?: Prisma.SortOrder
habits?: Prisma.HabitOrderByRelationAggregateInput
checkIns?: Prisma.CheckInOrderByRelationAggregateInput
checkLists?: Prisma.CheckListOrderByRelationAggregateInput
templates?: Prisma.CheckListTemplateOrderByRelationAggregateInput
_relevance?: Prisma.UserOrderByRelevanceInput
}
@@ -246,6 +254,10 @@ export type UserWhereUniqueInput = Prisma.AtLeast<{
password?: Prisma.StringFilter<"User"> | string
createdAt?: Prisma.DateTimeFilter<"User"> | Date | string
updatedAt?: Prisma.DateTimeFilter<"User"> | Date | string
habits?: Prisma.HabitListRelationFilter
checkIns?: Prisma.CheckInListRelationFilter
checkLists?: Prisma.CheckListListRelationFilter
templates?: Prisma.CheckListTemplateListRelationFilter
}, "id" | "email">
export type UserOrderByWithAggregationInput = {
@@ -280,6 +292,10 @@ export type UserCreateInput = {
password: string
createdAt?: Date | string
updatedAt?: Date | string
habits?: Prisma.HabitCreateNestedManyWithoutUserInput
checkIns?: Prisma.CheckInCreateNestedManyWithoutUserInput
checkLists?: Prisma.CheckListCreateNestedManyWithoutUserInput
templates?: Prisma.CheckListTemplateCreateNestedManyWithoutUserInput
}
export type UserUncheckedCreateInput = {
@@ -289,6 +305,10 @@ export type UserUncheckedCreateInput = {
password: string
createdAt?: Date | string
updatedAt?: Date | string
habits?: Prisma.HabitUncheckedCreateNestedManyWithoutUserInput
checkIns?: Prisma.CheckInUncheckedCreateNestedManyWithoutUserInput
checkLists?: Prisma.CheckListUncheckedCreateNestedManyWithoutUserInput
templates?: Prisma.CheckListTemplateUncheckedCreateNestedManyWithoutUserInput
}
export type UserUpdateInput = {
@@ -297,6 +317,10 @@ export type UserUpdateInput = {
password?: Prisma.StringFieldUpdateOperationsInput | string
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
habits?: Prisma.HabitUpdateManyWithoutUserNestedInput
checkIns?: Prisma.CheckInUpdateManyWithoutUserNestedInput
checkLists?: Prisma.CheckListUpdateManyWithoutUserNestedInput
templates?: Prisma.CheckListTemplateUpdateManyWithoutUserNestedInput
}
export type UserUncheckedUpdateInput = {
@@ -306,6 +330,10 @@ export type UserUncheckedUpdateInput = {
password?: Prisma.StringFieldUpdateOperationsInput | string
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
habits?: Prisma.HabitUncheckedUpdateManyWithoutUserNestedInput
checkIns?: Prisma.CheckInUncheckedUpdateManyWithoutUserNestedInput
checkLists?: Prisma.CheckListUncheckedUpdateManyWithoutUserNestedInput
templates?: Prisma.CheckListTemplateUncheckedUpdateManyWithoutUserNestedInput
}
export type UserCreateManyInput = {
@@ -375,6 +403,16 @@ export type UserSumOrderByAggregateInput = {
id?: Prisma.SortOrder
}
export type UserScalarRelationFilter = {
is?: Prisma.UserWhereInput
isNot?: Prisma.UserWhereInput
}
export type UserNullableScalarRelationFilter = {
is?: Prisma.UserWhereInput | null
isNot?: Prisma.UserWhereInput | null
}
export type StringFieldUpdateOperationsInput = {
set?: string
}
@@ -391,6 +429,368 @@ export type IntFieldUpdateOperationsInput = {
divide?: number
}
export type UserCreateNestedOneWithoutHabitsInput = {
create?: Prisma.XOR<Prisma.UserCreateWithoutHabitsInput, Prisma.UserUncheckedCreateWithoutHabitsInput>
connectOrCreate?: Prisma.UserCreateOrConnectWithoutHabitsInput
connect?: Prisma.UserWhereUniqueInput
}
export type UserUpdateOneRequiredWithoutHabitsNestedInput = {
create?: Prisma.XOR<Prisma.UserCreateWithoutHabitsInput, Prisma.UserUncheckedCreateWithoutHabitsInput>
connectOrCreate?: Prisma.UserCreateOrConnectWithoutHabitsInput
upsert?: Prisma.UserUpsertWithoutHabitsInput
connect?: Prisma.UserWhereUniqueInput
update?: Prisma.XOR<Prisma.XOR<Prisma.UserUpdateToOneWithWhereWithoutHabitsInput, Prisma.UserUpdateWithoutHabitsInput>, Prisma.UserUncheckedUpdateWithoutHabitsInput>
}
export type UserCreateNestedOneWithoutCheckInsInput = {
create?: Prisma.XOR<Prisma.UserCreateWithoutCheckInsInput, Prisma.UserUncheckedCreateWithoutCheckInsInput>
connectOrCreate?: Prisma.UserCreateOrConnectWithoutCheckInsInput
connect?: Prisma.UserWhereUniqueInput
}
export type UserUpdateOneRequiredWithoutCheckInsNestedInput = {
create?: Prisma.XOR<Prisma.UserCreateWithoutCheckInsInput, Prisma.UserUncheckedCreateWithoutCheckInsInput>
connectOrCreate?: Prisma.UserCreateOrConnectWithoutCheckInsInput
upsert?: Prisma.UserUpsertWithoutCheckInsInput
connect?: Prisma.UserWhereUniqueInput
update?: Prisma.XOR<Prisma.XOR<Prisma.UserUpdateToOneWithWhereWithoutCheckInsInput, Prisma.UserUpdateWithoutCheckInsInput>, Prisma.UserUncheckedUpdateWithoutCheckInsInput>
}
export type UserCreateNestedOneWithoutCheckListsInput = {
create?: Prisma.XOR<Prisma.UserCreateWithoutCheckListsInput, Prisma.UserUncheckedCreateWithoutCheckListsInput>
connectOrCreate?: Prisma.UserCreateOrConnectWithoutCheckListsInput
connect?: Prisma.UserWhereUniqueInput
}
export type UserUpdateOneRequiredWithoutCheckListsNestedInput = {
create?: Prisma.XOR<Prisma.UserCreateWithoutCheckListsInput, Prisma.UserUncheckedCreateWithoutCheckListsInput>
connectOrCreate?: Prisma.UserCreateOrConnectWithoutCheckListsInput
upsert?: Prisma.UserUpsertWithoutCheckListsInput
connect?: Prisma.UserWhereUniqueInput
update?: Prisma.XOR<Prisma.XOR<Prisma.UserUpdateToOneWithWhereWithoutCheckListsInput, Prisma.UserUpdateWithoutCheckListsInput>, Prisma.UserUncheckedUpdateWithoutCheckListsInput>
}
export type UserCreateNestedOneWithoutTemplatesInput = {
create?: Prisma.XOR<Prisma.UserCreateWithoutTemplatesInput, Prisma.UserUncheckedCreateWithoutTemplatesInput>
connectOrCreate?: Prisma.UserCreateOrConnectWithoutTemplatesInput
connect?: Prisma.UserWhereUniqueInput
}
export type UserUpdateOneWithoutTemplatesNestedInput = {
create?: Prisma.XOR<Prisma.UserCreateWithoutTemplatesInput, Prisma.UserUncheckedCreateWithoutTemplatesInput>
connectOrCreate?: Prisma.UserCreateOrConnectWithoutTemplatesInput
upsert?: Prisma.UserUpsertWithoutTemplatesInput
disconnect?: Prisma.UserWhereInput | boolean
delete?: Prisma.UserWhereInput | boolean
connect?: Prisma.UserWhereUniqueInput
update?: Prisma.XOR<Prisma.XOR<Prisma.UserUpdateToOneWithWhereWithoutTemplatesInput, Prisma.UserUpdateWithoutTemplatesInput>, Prisma.UserUncheckedUpdateWithoutTemplatesInput>
}
export type UserCreateWithoutHabitsInput = {
name: string
email: string
password: string
createdAt?: Date | string
updatedAt?: Date | string
checkIns?: Prisma.CheckInCreateNestedManyWithoutUserInput
checkLists?: Prisma.CheckListCreateNestedManyWithoutUserInput
templates?: Prisma.CheckListTemplateCreateNestedManyWithoutUserInput
}
export type UserUncheckedCreateWithoutHabitsInput = {
id?: number
name: string
email: string
password: string
createdAt?: Date | string
updatedAt?: Date | string
checkIns?: Prisma.CheckInUncheckedCreateNestedManyWithoutUserInput
checkLists?: Prisma.CheckListUncheckedCreateNestedManyWithoutUserInput
templates?: Prisma.CheckListTemplateUncheckedCreateNestedManyWithoutUserInput
}
export type UserCreateOrConnectWithoutHabitsInput = {
where: Prisma.UserWhereUniqueInput
create: Prisma.XOR<Prisma.UserCreateWithoutHabitsInput, Prisma.UserUncheckedCreateWithoutHabitsInput>
}
export type UserUpsertWithoutHabitsInput = {
update: Prisma.XOR<Prisma.UserUpdateWithoutHabitsInput, Prisma.UserUncheckedUpdateWithoutHabitsInput>
create: Prisma.XOR<Prisma.UserCreateWithoutHabitsInput, Prisma.UserUncheckedCreateWithoutHabitsInput>
where?: Prisma.UserWhereInput
}
export type UserUpdateToOneWithWhereWithoutHabitsInput = {
where?: Prisma.UserWhereInput
data: Prisma.XOR<Prisma.UserUpdateWithoutHabitsInput, Prisma.UserUncheckedUpdateWithoutHabitsInput>
}
export type UserUpdateWithoutHabitsInput = {
name?: Prisma.StringFieldUpdateOperationsInput | string
email?: Prisma.StringFieldUpdateOperationsInput | string
password?: Prisma.StringFieldUpdateOperationsInput | string
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
checkIns?: Prisma.CheckInUpdateManyWithoutUserNestedInput
checkLists?: Prisma.CheckListUpdateManyWithoutUserNestedInput
templates?: Prisma.CheckListTemplateUpdateManyWithoutUserNestedInput
}
export type UserUncheckedUpdateWithoutHabitsInput = {
id?: Prisma.IntFieldUpdateOperationsInput | number
name?: Prisma.StringFieldUpdateOperationsInput | string
email?: Prisma.StringFieldUpdateOperationsInput | string
password?: Prisma.StringFieldUpdateOperationsInput | string
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
checkIns?: Prisma.CheckInUncheckedUpdateManyWithoutUserNestedInput
checkLists?: Prisma.CheckListUncheckedUpdateManyWithoutUserNestedInput
templates?: Prisma.CheckListTemplateUncheckedUpdateManyWithoutUserNestedInput
}
export type UserCreateWithoutCheckInsInput = {
name: string
email: string
password: string
createdAt?: Date | string
updatedAt?: Date | string
habits?: Prisma.HabitCreateNestedManyWithoutUserInput
checkLists?: Prisma.CheckListCreateNestedManyWithoutUserInput
templates?: Prisma.CheckListTemplateCreateNestedManyWithoutUserInput
}
export type UserUncheckedCreateWithoutCheckInsInput = {
id?: number
name: string
email: string
password: string
createdAt?: Date | string
updatedAt?: Date | string
habits?: Prisma.HabitUncheckedCreateNestedManyWithoutUserInput
checkLists?: Prisma.CheckListUncheckedCreateNestedManyWithoutUserInput
templates?: Prisma.CheckListTemplateUncheckedCreateNestedManyWithoutUserInput
}
export type UserCreateOrConnectWithoutCheckInsInput = {
where: Prisma.UserWhereUniqueInput
create: Prisma.XOR<Prisma.UserCreateWithoutCheckInsInput, Prisma.UserUncheckedCreateWithoutCheckInsInput>
}
export type UserUpsertWithoutCheckInsInput = {
update: Prisma.XOR<Prisma.UserUpdateWithoutCheckInsInput, Prisma.UserUncheckedUpdateWithoutCheckInsInput>
create: Prisma.XOR<Prisma.UserCreateWithoutCheckInsInput, Prisma.UserUncheckedCreateWithoutCheckInsInput>
where?: Prisma.UserWhereInput
}
export type UserUpdateToOneWithWhereWithoutCheckInsInput = {
where?: Prisma.UserWhereInput
data: Prisma.XOR<Prisma.UserUpdateWithoutCheckInsInput, Prisma.UserUncheckedUpdateWithoutCheckInsInput>
}
export type UserUpdateWithoutCheckInsInput = {
name?: Prisma.StringFieldUpdateOperationsInput | string
email?: Prisma.StringFieldUpdateOperationsInput | string
password?: Prisma.StringFieldUpdateOperationsInput | string
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
habits?: Prisma.HabitUpdateManyWithoutUserNestedInput
checkLists?: Prisma.CheckListUpdateManyWithoutUserNestedInput
templates?: Prisma.CheckListTemplateUpdateManyWithoutUserNestedInput
}
export type UserUncheckedUpdateWithoutCheckInsInput = {
id?: Prisma.IntFieldUpdateOperationsInput | number
name?: Prisma.StringFieldUpdateOperationsInput | string
email?: Prisma.StringFieldUpdateOperationsInput | string
password?: Prisma.StringFieldUpdateOperationsInput | string
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
habits?: Prisma.HabitUncheckedUpdateManyWithoutUserNestedInput
checkLists?: Prisma.CheckListUncheckedUpdateManyWithoutUserNestedInput
templates?: Prisma.CheckListTemplateUncheckedUpdateManyWithoutUserNestedInput
}
export type UserCreateWithoutCheckListsInput = {
name: string
email: string
password: string
createdAt?: Date | string
updatedAt?: Date | string
habits?: Prisma.HabitCreateNestedManyWithoutUserInput
checkIns?: Prisma.CheckInCreateNestedManyWithoutUserInput
templates?: Prisma.CheckListTemplateCreateNestedManyWithoutUserInput
}
export type UserUncheckedCreateWithoutCheckListsInput = {
id?: number
name: string
email: string
password: string
createdAt?: Date | string
updatedAt?: Date | string
habits?: Prisma.HabitUncheckedCreateNestedManyWithoutUserInput
checkIns?: Prisma.CheckInUncheckedCreateNestedManyWithoutUserInput
templates?: Prisma.CheckListTemplateUncheckedCreateNestedManyWithoutUserInput
}
export type UserCreateOrConnectWithoutCheckListsInput = {
where: Prisma.UserWhereUniqueInput
create: Prisma.XOR<Prisma.UserCreateWithoutCheckListsInput, Prisma.UserUncheckedCreateWithoutCheckListsInput>
}
export type UserUpsertWithoutCheckListsInput = {
update: Prisma.XOR<Prisma.UserUpdateWithoutCheckListsInput, Prisma.UserUncheckedUpdateWithoutCheckListsInput>
create: Prisma.XOR<Prisma.UserCreateWithoutCheckListsInput, Prisma.UserUncheckedCreateWithoutCheckListsInput>
where?: Prisma.UserWhereInput
}
export type UserUpdateToOneWithWhereWithoutCheckListsInput = {
where?: Prisma.UserWhereInput
data: Prisma.XOR<Prisma.UserUpdateWithoutCheckListsInput, Prisma.UserUncheckedUpdateWithoutCheckListsInput>
}
export type UserUpdateWithoutCheckListsInput = {
name?: Prisma.StringFieldUpdateOperationsInput | string
email?: Prisma.StringFieldUpdateOperationsInput | string
password?: Prisma.StringFieldUpdateOperationsInput | string
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
habits?: Prisma.HabitUpdateManyWithoutUserNestedInput
checkIns?: Prisma.CheckInUpdateManyWithoutUserNestedInput
templates?: Prisma.CheckListTemplateUpdateManyWithoutUserNestedInput
}
export type UserUncheckedUpdateWithoutCheckListsInput = {
id?: Prisma.IntFieldUpdateOperationsInput | number
name?: Prisma.StringFieldUpdateOperationsInput | string
email?: Prisma.StringFieldUpdateOperationsInput | string
password?: Prisma.StringFieldUpdateOperationsInput | string
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
habits?: Prisma.HabitUncheckedUpdateManyWithoutUserNestedInput
checkIns?: Prisma.CheckInUncheckedUpdateManyWithoutUserNestedInput
templates?: Prisma.CheckListTemplateUncheckedUpdateManyWithoutUserNestedInput
}
export type UserCreateWithoutTemplatesInput = {
name: string
email: string
password: string
createdAt?: Date | string
updatedAt?: Date | string
habits?: Prisma.HabitCreateNestedManyWithoutUserInput
checkIns?: Prisma.CheckInCreateNestedManyWithoutUserInput
checkLists?: Prisma.CheckListCreateNestedManyWithoutUserInput
}
export type UserUncheckedCreateWithoutTemplatesInput = {
id?: number
name: string
email: string
password: string
createdAt?: Date | string
updatedAt?: Date | string
habits?: Prisma.HabitUncheckedCreateNestedManyWithoutUserInput
checkIns?: Prisma.CheckInUncheckedCreateNestedManyWithoutUserInput
checkLists?: Prisma.CheckListUncheckedCreateNestedManyWithoutUserInput
}
export type UserCreateOrConnectWithoutTemplatesInput = {
where: Prisma.UserWhereUniqueInput
create: Prisma.XOR<Prisma.UserCreateWithoutTemplatesInput, Prisma.UserUncheckedCreateWithoutTemplatesInput>
}
export type UserUpsertWithoutTemplatesInput = {
update: Prisma.XOR<Prisma.UserUpdateWithoutTemplatesInput, Prisma.UserUncheckedUpdateWithoutTemplatesInput>
create: Prisma.XOR<Prisma.UserCreateWithoutTemplatesInput, Prisma.UserUncheckedCreateWithoutTemplatesInput>
where?: Prisma.UserWhereInput
}
export type UserUpdateToOneWithWhereWithoutTemplatesInput = {
where?: Prisma.UserWhereInput
data: Prisma.XOR<Prisma.UserUpdateWithoutTemplatesInput, Prisma.UserUncheckedUpdateWithoutTemplatesInput>
}
export type UserUpdateWithoutTemplatesInput = {
name?: Prisma.StringFieldUpdateOperationsInput | string
email?: Prisma.StringFieldUpdateOperationsInput | string
password?: Prisma.StringFieldUpdateOperationsInput | string
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
habits?: Prisma.HabitUpdateManyWithoutUserNestedInput
checkIns?: Prisma.CheckInUpdateManyWithoutUserNestedInput
checkLists?: Prisma.CheckListUpdateManyWithoutUserNestedInput
}
export type UserUncheckedUpdateWithoutTemplatesInput = {
id?: Prisma.IntFieldUpdateOperationsInput | number
name?: Prisma.StringFieldUpdateOperationsInput | string
email?: Prisma.StringFieldUpdateOperationsInput | string
password?: Prisma.StringFieldUpdateOperationsInput | string
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
habits?: Prisma.HabitUncheckedUpdateManyWithoutUserNestedInput
checkIns?: Prisma.CheckInUncheckedUpdateManyWithoutUserNestedInput
checkLists?: Prisma.CheckListUncheckedUpdateManyWithoutUserNestedInput
}
/**
* Count Type UserCountOutputType
*/
export type UserCountOutputType = {
habits: number
checkIns: number
checkLists: number
templates: number
}
export type UserCountOutputTypeSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
habits?: boolean | UserCountOutputTypeCountHabitsArgs
checkIns?: boolean | UserCountOutputTypeCountCheckInsArgs
checkLists?: boolean | UserCountOutputTypeCountCheckListsArgs
templates?: boolean | UserCountOutputTypeCountTemplatesArgs
}
/**
* UserCountOutputType without action
*/
export type UserCountOutputTypeDefaultArgs<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
/**
* Select specific fields to fetch from the UserCountOutputType
*/
select?: Prisma.UserCountOutputTypeSelect<ExtArgs> | null
}
/**
* UserCountOutputType without action
*/
export type UserCountOutputTypeCountHabitsArgs<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
where?: Prisma.HabitWhereInput
}
/**
* UserCountOutputType without action
*/
export type UserCountOutputTypeCountCheckInsArgs<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
where?: Prisma.CheckInWhereInput
}
/**
* UserCountOutputType without action
*/
export type UserCountOutputTypeCountCheckListsArgs<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
where?: Prisma.CheckListWhereInput
}
/**
* UserCountOutputType without action
*/
export type UserCountOutputTypeCountTemplatesArgs<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
where?: Prisma.CheckListTemplateWhereInput
}
export type UserSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetSelect<{
@@ -400,6 +800,11 @@ export type UserSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = r
password?: boolean
createdAt?: boolean
updatedAt?: boolean
habits?: boolean | Prisma.User$habitsArgs<ExtArgs>
checkIns?: boolean | Prisma.User$checkInsArgs<ExtArgs>
checkLists?: boolean | Prisma.User$checkListsArgs<ExtArgs>
templates?: boolean | Prisma.User$templatesArgs<ExtArgs>
_count?: boolean | Prisma.UserCountOutputTypeDefaultArgs<ExtArgs>
}, ExtArgs["result"]["user"]>
@@ -414,10 +819,22 @@ export type UserSelectScalar = {
}
export type UserOmit<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetOmit<"id" | "name" | "email" | "password" | "createdAt" | "updatedAt", ExtArgs["result"]["user"]>
export type UserInclude<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
habits?: boolean | Prisma.User$habitsArgs<ExtArgs>
checkIns?: boolean | Prisma.User$checkInsArgs<ExtArgs>
checkLists?: boolean | Prisma.User$checkListsArgs<ExtArgs>
templates?: boolean | Prisma.User$templatesArgs<ExtArgs>
_count?: boolean | Prisma.UserCountOutputTypeDefaultArgs<ExtArgs>
}
export type $UserPayload<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
name: "User"
objects: {}
objects: {
habits: Prisma.$HabitPayload<ExtArgs>[]
checkIns: Prisma.$CheckInPayload<ExtArgs>[]
checkLists: Prisma.$CheckListPayload<ExtArgs>[]
templates: Prisma.$CheckListTemplatePayload<ExtArgs>[]
}
scalars: runtime.Types.Extensions.GetPayloadResult<{
id: number
name: string
@@ -765,6 +1182,10 @@ readonly fields: UserFieldRefs;
*/
export interface Prisma__UserClient<T, Null = never, ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs, GlobalOmitOptions = {}> extends Prisma.PrismaPromise<T> {
readonly [Symbol.toStringTag]: "PrismaPromise"
habits<T extends Prisma.User$habitsArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.User$habitsArgs<ExtArgs>>): Prisma.PrismaPromise<runtime.Types.Result.GetResult<Prisma.$HabitPayload<ExtArgs>, T, "findMany", GlobalOmitOptions> | Null>
checkIns<T extends Prisma.User$checkInsArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.User$checkInsArgs<ExtArgs>>): Prisma.PrismaPromise<runtime.Types.Result.GetResult<Prisma.$CheckInPayload<ExtArgs>, T, "findMany", GlobalOmitOptions> | Null>
checkLists<T extends Prisma.User$checkListsArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.User$checkListsArgs<ExtArgs>>): Prisma.PrismaPromise<runtime.Types.Result.GetResult<Prisma.$CheckListPayload<ExtArgs>, T, "findMany", GlobalOmitOptions> | Null>
templates<T extends Prisma.User$templatesArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.User$templatesArgs<ExtArgs>>): Prisma.PrismaPromise<runtime.Types.Result.GetResult<Prisma.$CheckListTemplatePayload<ExtArgs>, T, "findMany", GlobalOmitOptions> | Null>
/**
* Attaches callbacks for the resolution and/or rejection of the Promise.
* @param onfulfilled The callback to execute when the Promise is resolved.
@@ -816,6 +1237,10 @@ export type UserFindUniqueArgs<ExtArgs extends runtime.Types.Extensions.Internal
* Omit specific fields from the User
*/
omit?: Prisma.UserOmit<ExtArgs> | null
/**
* Choose, which related nodes to fetch as well
*/
include?: Prisma.UserInclude<ExtArgs> | null
/**
* Filter, which User to fetch.
*/
@@ -834,6 +1259,10 @@ export type UserFindUniqueOrThrowArgs<ExtArgs extends runtime.Types.Extensions.I
* Omit specific fields from the User
*/
omit?: Prisma.UserOmit<ExtArgs> | null
/**
* Choose, which related nodes to fetch as well
*/
include?: Prisma.UserInclude<ExtArgs> | null
/**
* Filter, which User to fetch.
*/
@@ -852,6 +1281,10 @@ export type UserFindFirstArgs<ExtArgs extends runtime.Types.Extensions.InternalA
* Omit specific fields from the User
*/
omit?: Prisma.UserOmit<ExtArgs> | null
/**
* Choose, which related nodes to fetch as well
*/
include?: Prisma.UserInclude<ExtArgs> | null
/**
* Filter, which User to fetch.
*/
@@ -900,6 +1333,10 @@ export type UserFindFirstOrThrowArgs<ExtArgs extends runtime.Types.Extensions.In
* Omit specific fields from the User
*/
omit?: Prisma.UserOmit<ExtArgs> | null
/**
* Choose, which related nodes to fetch as well
*/
include?: Prisma.UserInclude<ExtArgs> | null
/**
* Filter, which User to fetch.
*/
@@ -948,6 +1385,10 @@ export type UserFindManyArgs<ExtArgs extends runtime.Types.Extensions.InternalAr
* Omit specific fields from the User
*/
omit?: Prisma.UserOmit<ExtArgs> | null
/**
* Choose, which related nodes to fetch as well
*/
include?: Prisma.UserInclude<ExtArgs> | null
/**
* Filter, which Users to fetch.
*/
@@ -996,6 +1437,10 @@ export type UserCreateArgs<ExtArgs extends runtime.Types.Extensions.InternalArgs
* Omit specific fields from the User
*/
omit?: Prisma.UserOmit<ExtArgs> | null
/**
* Choose, which related nodes to fetch as well
*/
include?: Prisma.UserInclude<ExtArgs> | null
/**
* The data needed to create a User.
*/
@@ -1025,6 +1470,10 @@ export type UserUpdateArgs<ExtArgs extends runtime.Types.Extensions.InternalArgs
* Omit specific fields from the User
*/
omit?: Prisma.UserOmit<ExtArgs> | null
/**
* Choose, which related nodes to fetch as well
*/
include?: Prisma.UserInclude<ExtArgs> | null
/**
* The data needed to update a User.
*/
@@ -1065,6 +1514,10 @@ export type UserUpsertArgs<ExtArgs extends runtime.Types.Extensions.InternalArgs
* Omit specific fields from the User
*/
omit?: Prisma.UserOmit<ExtArgs> | null
/**
* Choose, which related nodes to fetch as well
*/
include?: Prisma.UserInclude<ExtArgs> | null
/**
* The filter to search for the User to update in case it exists.
*/
@@ -1091,6 +1544,10 @@ export type UserDeleteArgs<ExtArgs extends runtime.Types.Extensions.InternalArgs
* Omit specific fields from the User
*/
omit?: Prisma.UserOmit<ExtArgs> | null
/**
* Choose, which related nodes to fetch as well
*/
include?: Prisma.UserInclude<ExtArgs> | null
/**
* Filter which User to delete.
*/
@@ -1111,6 +1568,102 @@ export type UserDeleteManyArgs<ExtArgs extends runtime.Types.Extensions.Internal
limit?: number
}
/**
* User.habits
*/
export type User$habitsArgs<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
/**
* Select specific fields to fetch from the Habit
*/
select?: Prisma.HabitSelect<ExtArgs> | null
/**
* Omit specific fields from the Habit
*/
omit?: Prisma.HabitOmit<ExtArgs> | null
/**
* Choose, which related nodes to fetch as well
*/
include?: Prisma.HabitInclude<ExtArgs> | null
where?: Prisma.HabitWhereInput
orderBy?: Prisma.HabitOrderByWithRelationInput | Prisma.HabitOrderByWithRelationInput[]
cursor?: Prisma.HabitWhereUniqueInput
take?: number
skip?: number
distinct?: Prisma.HabitScalarFieldEnum | Prisma.HabitScalarFieldEnum[]
}
/**
* User.checkIns
*/
export type User$checkInsArgs<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
/**
* Select specific fields to fetch from the CheckIn
*/
select?: Prisma.CheckInSelect<ExtArgs> | null
/**
* Omit specific fields from the CheckIn
*/
omit?: Prisma.CheckInOmit<ExtArgs> | null
/**
* Choose, which related nodes to fetch as well
*/
include?: Prisma.CheckInInclude<ExtArgs> | null
where?: Prisma.CheckInWhereInput
orderBy?: Prisma.CheckInOrderByWithRelationInput | Prisma.CheckInOrderByWithRelationInput[]
cursor?: Prisma.CheckInWhereUniqueInput
take?: number
skip?: number
distinct?: Prisma.CheckInScalarFieldEnum | Prisma.CheckInScalarFieldEnum[]
}
/**
* User.checkLists
*/
export type User$checkListsArgs<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
/**
* Select specific fields to fetch from the CheckList
*/
select?: Prisma.CheckListSelect<ExtArgs> | null
/**
* Omit specific fields from the CheckList
*/
omit?: Prisma.CheckListOmit<ExtArgs> | null
/**
* Choose, which related nodes to fetch as well
*/
include?: Prisma.CheckListInclude<ExtArgs> | null
where?: Prisma.CheckListWhereInput
orderBy?: Prisma.CheckListOrderByWithRelationInput | Prisma.CheckListOrderByWithRelationInput[]
cursor?: Prisma.CheckListWhereUniqueInput
take?: number
skip?: number
distinct?: Prisma.CheckListScalarFieldEnum | Prisma.CheckListScalarFieldEnum[]
}
/**
* User.templates
*/
export type User$templatesArgs<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
/**
* Select specific fields to fetch from the CheckListTemplate
*/
select?: Prisma.CheckListTemplateSelect<ExtArgs> | null
/**
* Omit specific fields from the CheckListTemplate
*/
omit?: Prisma.CheckListTemplateOmit<ExtArgs> | null
/**
* Choose, which related nodes to fetch as well
*/
include?: Prisma.CheckListTemplateInclude<ExtArgs> | null
where?: Prisma.CheckListTemplateWhereInput
orderBy?: Prisma.CheckListTemplateOrderByWithRelationInput | Prisma.CheckListTemplateOrderByWithRelationInput[]
cursor?: Prisma.CheckListTemplateWhereUniqueInput
take?: number
skip?: number
distinct?: Prisma.CheckListTemplateScalarFieldEnum | Prisma.CheckListTemplateScalarFieldEnum[]
}
/**
* User without action
*/
@@ -1123,4 +1676,8 @@ export type UserDefaultArgs<ExtArgs extends runtime.Types.Extensions.InternalArg
* Omit specific fields from the User
*/
omit?: Prisma.UserOmit<ExtArgs> | null
/**
* Choose, which related nodes to fetch as well
*/
include?: Prisma.UserInclude<ExtArgs> | null
}
@@ -0,0 +1,99 @@
-- CreateTable
CREATE TABLE `Habit` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`userId` INTEGER NOT NULL,
`name` VARCHAR(191) NOT NULL,
`period` VARCHAR(191) NOT NULL DEFAULT 'day',
`target` INTEGER NOT NULL DEFAULT 1,
`color` VARCHAR(191) NOT NULL DEFAULT '#1677ff',
`icon` VARCHAR(191) NOT NULL DEFAULT '',
`note` VARCHAR(191) NOT NULL DEFAULT '',
`archived` BOOLEAN NOT NULL DEFAULT false,
`sortOrder` INTEGER NOT NULL DEFAULT 0,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
INDEX `Habit_userId_archived_sortOrder_idx`(`userId`, `archived`, `sortOrder`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `CheckIn` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`habitId` INTEGER NOT NULL,
`userId` INTEGER NOT NULL,
`date` VARCHAR(10) NOT NULL,
`seq` INTEGER NOT NULL DEFAULT 1,
`note` VARCHAR(191) NOT NULL DEFAULT '',
`source` VARCHAR(191) NOT NULL DEFAULT 'user',
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
UNIQUE INDEX `CheckIn_habitId_date_seq_key`(`habitId`, `date`, `seq`),
INDEX `CheckIn_userId_date_idx`(`userId`, `date`),
INDEX `CheckIn_habitId_date_idx`(`habitId`, `date`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `CheckList` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`userId` INTEGER NOT NULL,
`title` VARCHAR(191) NOT NULL,
`scene` VARCHAR(191) NOT NULL DEFAULT '',
`note` VARCHAR(191) NOT NULL DEFAULT '',
`archived` BOOLEAN NOT NULL DEFAULT false,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
INDEX `CheckList_userId_archived_idx`(`userId`, `archived`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `CheckListItem` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`listId` INTEGER NOT NULL,
`title` VARCHAR(191) NOT NULL,
`note` VARCHAR(191) NOT NULL DEFAULT '',
`done` BOOLEAN NOT NULL DEFAULT false,
`doneAt` DATETIME(3) NULL,
`sortOrder` INTEGER NOT NULL DEFAULT 0,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
INDEX `CheckListItem_listId_sortOrder_idx`(`listId`, `sortOrder`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `CheckListTemplate` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`userId` INTEGER NULL,
`name` VARCHAR(191) NOT NULL,
`scene` VARCHAR(191) NOT NULL DEFAULT '',
`items` JSON NOT NULL,
`builtin` BOOLEAN NOT NULL DEFAULT false,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
INDEX `CheckListTemplate_userId_builtin_idx`(`userId`, `builtin`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- AddForeignKey
ALTER TABLE `Habit` ADD CONSTRAINT `Habit_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `User`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `CheckIn` ADD CONSTRAINT `CheckIn_habitId_fkey` FOREIGN KEY (`habitId`) REFERENCES `Habit`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `CheckIn` ADD CONSTRAINT `CheckIn_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `User`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `CheckList` ADD CONSTRAINT `CheckList_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `User`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `CheckListItem` ADD CONSTRAINT `CheckListItem_listId_fkey` FOREIGN KEY (`listId`) REFERENCES `CheckList`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `CheckListTemplate` ADD CONSTRAINT `CheckListTemplate_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `User`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
+93
View File
@@ -19,6 +19,12 @@ model User {
password String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// V0.2 打卡 / 清单: 用户删除时级联清理
habits Habit[]
checkIns CheckIn[]
checkLists CheckList[]
templates CheckListTemplate[]
}
model Article {
@@ -281,4 +287,91 @@ model MihomeEnvHour {
@@index([deviceId, hour])
}
// ==================== 打卡 / 日历 / 清单 (V0.2) ====================
// 打卡项: 周期性目标 (每天 / 每周 / 每月 / 每年 n 次)
model Habit {
id Int @id @default(autoincrement())
userId Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
name String
period String @default("day") // day | week | month | year
target Int @default(1) // 周期内目标次数 n (>=1)
color String @default("#1677ff")
icon String @default("") // emoji 或图标名, 预留
note String @default("")
archived Boolean @default(false) // 归档: 不出现在今日待办, 历史保留
sortOrder Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
records CheckIn[]
@@index([userId, archived, sortOrder])
}
// 打卡记录: 一次打卡 = 一条; 同日多次 => seq 递增 (1..n, 超额继续累加)
model CheckIn {
id Int @id @default(autoincrement())
habitId Int
habit Habit @relation(fields: [habitId], references: [id], onDelete: Cascade)
userId Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
date String @db.VarChar(10) // 本地日历日 "YYYY-MM-DD" (Asia/Shanghai)
seq Int @default(1) // 当日第几次
note String @default("")
source String @default("user") // user | backfill (补打)
createdAt DateTime @default(now())
@@unique([habitId, date, seq])
@@index([userId, date])
@@index([habitId, date])
}
// 检查清单: 出差 / 回老家 等场景的一次性清单
model CheckList {
id Int @id @default(autoincrement())
userId Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
title String
scene String @default("") // 出差 | 回老家 | 自定义
note String @default("")
archived Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
items CheckListItem[]
@@index([userId, archived])
}
// 检查项: 勾选即完成
model CheckListItem {
id Int @id @default(autoincrement())
listId Int
list CheckList @relation(fields: [listId], references: [id], onDelete: Cascade)
title String
note String @default("")
done Boolean @default(false)
doneAt DateTime?
sortOrder Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([listId, sortOrder])
}
// 清单模板: builtin = 系统预置 (userId 为空); 否则为用户自定义
model CheckListTemplate {
id Int @id @default(autoincrement())
userId Int?
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
name String
scene String @default("")
items Json // [{ title, note }]
builtin Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([userId, builtin])
}
+18
View File
@@ -17,6 +17,24 @@
- `BUILD_ID` 每次编译自动生成(见 `vite.config.js` 的 `__BUILD_ID__`),无需手动维护。
- `ota-package/latest.json` 为运行时产物,不纳入版本管理。
## 分支与 CI/CD (Gitea Actions)
分支按版本命名:`dev-0.1`、`dev-0.2`…… 对应 `docs/V<版本>/` 下的需求/设计文档。
| 分支 | 触发的 workflow | 行为 |
| --- | --- | --- |
| `main` / `master` | `docker-build.yml` | 生产发布:构建前端 → 生成签名 OTA 包 → 打 Docker 镜像 → 数据库自动升级 → 重启容器 `iboard` (宿主 3001) |
| `dev-*` | `docker-build.yml` | **与生产同环境**:推送即构建、迁移数据库、部署(开发分支直接顶掉生产,含 OTA 热更) |
| `android-*` | `android-build.yml` | 打签名 release APK 并上传到 Gitea Generic Package Registry |
| 手动 | `ota-build.yml` / `sync-registry.yml` | 单独出 OTA 包 / 同步 secrets 到注册中心 |
数据库升级约定:
- 唯一入口是 `prisma/migrations/` 下的迁移文件,由 `npx prisma migrate deploy` 在**启新容器之前**执行
(幂等,无新迁移时为 no-op);迁移失败会中断 workflow,不会用新镜像顶掉仍在运行的旧容器。
- `dev-*` 与 `main/master` 共用同一个 MariaDB 库(同一个 `DATABASE_URL` secret),
因此开发分支上的迁移会直接作用到该库结构,合入前请确认迁移可回滚。
- 本地改了 `prisma/schema.prisma` 后:写迁移 SQL → `npx prisma migrate deploy` 验证 → 提交迁移目录。
- 首页主要是放经济文章
- 数据主要是放各种经济数据和图表。
- 策略是放按照经济理论组织的相关数据和图表的对比。
+120
View File
@@ -0,0 +1,120 @@
// 打卡周期计算: 全部以 Asia/Shanghai 的"本地日历日"为准
// 日期统一用 "YYYY-MM-DD" 字符串 (字典序 = 时间序, 可直接用于范围查询)
export const TZ = 'Asia/Shanghai'
const FMT = new Intl.DateTimeFormat('en-CA', {
timeZone: TZ,
year: 'numeric',
month: '2-digit',
day: '2-digit',
})
/** 任意时间 -> 本地日历日 "YYYY-MM-DD" */
export function toDateStr(d = new Date()) {
return FMT.format(d instanceof Date ? d : new Date(d))
}
/** "YYYY-MM-DD" -> UTC 零点的 Date (仅用于日期运算, 不做时区展示) */
function toUTC(s) {
const [y, m, d] = String(s).split('-').map(Number)
return new Date(Date.UTC(y, (m || 1) - 1, d || 1))
}
function fromUTC(dt) {
return dt.toISOString().slice(0, 10)
}
export function addDays(s, n) {
const d = toUTC(s)
d.setUTCDate(d.getUTCDate() + n)
return fromUTC(d)
}
export function addMonths(s, n) {
const [y, m] = String(s).split('-').map(Number)
const first = new Date(Date.UTC(y, (m || 1) - 1 + n, 1))
const day = Number(String(s).slice(8, 10)) || 1
const lastDay = new Date(Date.UTC(first.getUTCFullYear(), first.getUTCMonth() + 1, 0)).getUTCDate()
first.setUTCDate(Math.min(day, lastDay))
return fromUTC(first)
}
/** 周一为一周第一天 */
export function startOfWeek(s) {
const dow = (toUTC(s).getUTCDay() + 6) % 7 // 0=周一
return addDays(s, -dow)
}
export function endOfWeek(s) {
return addDays(startOfWeek(s), 6)
}
export function startOfMonth(s) {
return String(s).slice(0, 8) + '01'
}
export function endOfMonth(s) {
const [y, m] = String(s).split('-').map(Number)
return fromUTC(new Date(Date.UTC(y, m || 1, 0)))
}
export function startOfYear(s) {
return String(s).slice(0, 4) + '-01-01'
}
export function endOfYear(s) {
return String(s).slice(0, 4) + '-12-31'
}
export const PERIODS = ['day', 'week', 'month', 'year']
export const PERIOD_LABEL = { day: '每天', week: '每周', month: '每月', year: '每年' }
/** 周期窗口 [start, end] (闭区间, 本地日历日) */
export function periodRange(period, s) {
switch (period) {
case 'week': return { start: startOfWeek(s), end: endOfWeek(s) }
case 'month': return { start: startOfMonth(s), end: endOfMonth(s) }
case 'year': return { start: startOfYear(s), end: endOfYear(s) }
default: return { start: s, end: s }
}
}
/** 周期窗口内的天数 */
export function periodDays(period, s) {
const { start, end } = periodRange(period, s)
return Math.round((toUTC(end) - toUTC(start)) / 86400000) + 1
}
/**
* 日均期望次数: 用于日历上"某一天是否完成了当天应做的份额"
* - 每天 n 次 -> n
* - 每周 n 次 -> ceil(n / 7)
* - 每月 n 次 -> ceil(n / 当月天数)
* - 每年 n 次 -> ceil(n / 365)
* 环形进度仍以"周期完成度"(周期内累计次数 / n) 为准, 两者不冲突
*/
export function expectedPerDay(period, target, s) {
const n = Math.max(1, Number(target) || 1)
if (period === 'week') return Math.max(1, Math.ceil(n / 7))
if (period === 'month') return Math.max(1, Math.ceil(n / periodDays('month', s)))
if (period === 'year') return Math.max(1, Math.ceil(n / 365))
return n
}
/** 该周期在日期 d 之前是否已完整结束 (用于 streak 判断: 未结束的周期不算断签) */
export function periodIsClosed(period, d, today) {
const { end } = periodRange(period, d)
return end < today
}
/** 遍历 [start, end] 闭区间的每一天 */
export function eachDay(start, end, fn) {
let cur = start
let guard = 0
while (cur <= end && guard++ < 4000) {
fn(cur)
cur = addDays(cur, 1)
}
}
+5
View File
@@ -12,6 +12,8 @@ import otaRouter from './routes/ota.js'
import apizeroRouter from './routes/apizero.js'
import monitorsRouter from './routes/monitors.js'
import mihomeRouter from './routes/mihome.js'
import checkinRouter from './routes/checkin.js'
import checklistsRouter from './routes/checklists.js'
const PORT = Number(process.env.PORT) || 3001
const DIST_DIR = path.join(process.cwd(), 'dist')
@@ -36,6 +38,9 @@ app.use('/api', articlesRouter)
app.use('/api', apizeroRouter)
app.use('/api', monitorsRouter)
app.use('/api/mihome', mihomeRouter)
// V0.2 打卡 / 日历 / 清单
app.use('/api/checkin', checkinRouter)
app.use('/api/checklists', checklistsRouter)
// 静态资源 (Vite build 产物)
if (HAS_DIST) {
+328
View File
@@ -0,0 +1,328 @@
import { Router } from 'express'
import { prisma } from '../db.js'
import { authRequired } from '../middleware.js'
import {
toDateStr, addDays, periodRange, periodDays, expectedPerDay,
periodIsClosed, eachDay, PERIODS, PERIOD_LABEL,
} from '../checkin/period.js'
const router = Router()
router.use(authRequired)
// 补打卡允许的回溯天数 (F-CK-10)
const BACKFILL_DAYS = 7
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/
const MONTH_RE = /^\d{4}-\d{2}$/
function normDate(v) {
const s = String(v || '')
return DATE_RE.test(s) ? s : toDateStr()
}
// ---------- 统计辅助 ----------
// 记录 -> { habitId: { date: count } }
function groupRecords(records) {
const map = new Map()
for (const r of records) {
if (!map.has(r.habitId)) map.set(r.habitId, new Map())
const byDate = map.get(r.habitId)
byDate.set(r.date, (byDate.get(r.date) || 0) + 1)
}
return map
}
function countInRange(byDate, start, end) {
let n = 0
eachDay(start, end, (d) => { n += byDate.get(d) || 0 })
return n
}
// 连续达标的周期数: 当前周期未结束且未达标 -> 跳过(不算断签), 不是中断
function computeStreak(habit, byDate, today) {
let cursor = today
let streak = 0
for (let i = 0; i < 400; i++) {
const { start } = periodRange(habit.period, cursor)
const done = countInRange(byDate, start, periodRange(habit.period, cursor).end)
if (done >= habit.target) {
streak++
} else if (periodIsClosed(habit.period, cursor, today)) {
break
}
const prevEnd = addDays(start, -1)
if (prevEnd < '1970-01-01') break
cursor = prevEnd
}
return streak
}
async function loadActiveHabits(userId) {
return prisma.habit.findMany({
where: { userId, archived: false },
orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }],
})
}
// 组装单个打卡项的展示态
function buildItem(habit, byDate, date, today) {
const { start, end } = periodRange(habit.period, date)
const done = countInRange(byDate, start, end)
const target = Math.max(1, habit.target)
const onDate = byDate.get(date) || 0
const expPerDay = expectedPerDay(habit.period, target, date)
return {
id: habit.id,
name: habit.name,
period: habit.period,
periodLabel: PERIOD_LABEL[habit.period] || '每天',
target,
color: habit.color,
icon: habit.icon,
note: habit.note,
archived: habit.archived,
periodStart: start,
periodEnd: end,
periodDays: periodDays(habit.period, date),
done,
remaining: Math.max(0, target - done),
percent: Math.min(1, target > 0 ? done / target : 1),
completed: done >= target,
over: Math.max(0, done - target),
countOnDate: onDate,
expectedPerDay: expPerDay,
dayDone: onDate >= expPerDay,
streak: computeStreak(habit, byDate, today),
}
}
// 拉取 [start, end] 内的打卡记录并分组
async function loadRecords(userId, habits, ranges) {
let min = ranges[0]?.start
let max = ranges[0]?.end
for (const r of ranges) {
if (r.start < min) min = r.start
if (r.end > max) max = r.end
}
if (!min || !max) return new Map()
const records = await prisma.checkIn.findMany({
where: { userId, habitId: { in: habits.map((h) => h.id) }, date: { gte: min, lte: max } },
select: { habitId: true, date: true },
})
return groupRecords(records)
}
// ---------- 打卡项 ----------
// GET /api/checkin/habits?date=YYYY-MM-DD 今日(或指定日)待办 + 周期进度
router.get('/habits', async (req, res, next) => {
try {
const userId = req.user.sub
const today = toDateStr()
const date = normDate(req.query.date)
const habits = await loadActiveHabits(userId)
const ranges = habits.map((h) => periodRange(h.period, date))
const grouped = await loadRecords(userId, habits, ranges.length ? ranges : [{ start: date, end: date }])
const items = habits.map((h) => buildItem(h, grouped.get(h.id) || new Map(), date, today))
const doneCount = items.filter((i) => i.completed).length
res.json({ today, date, items, summary: { total: items.length, done: doneCount } })
} catch (e) { next(e) }
})
// POST /api/checkin/habits 新建打卡项
router.post('/habits', async (req, res, next) => {
try {
const userId = req.user.sub
const { name, period = 'day', target = 1, color = '#1677ff', icon = '', note = '' } = req.body || {}
const cleanName = String(name || '').trim()
if (!cleanName) return res.status(400).json({ error: '名称不能为空' })
if (cleanName.length > 20) return res.status(400).json({ error: '名称不能超过 20 个字' })
if (!PERIODS.includes(period)) return res.status(400).json({ error: '周期类型不合法' })
const t = Math.floor(Number(target))
if (!Number.isFinite(t) || t < 1) return res.status(400).json({ error: '目标次数需为 ≥1 的整数' })
const row = await prisma.habit.create({
data: { userId, name: cleanName, period, target: t, color: String(color), icon: String(icon), note: String(note || '') },
})
res.json({ item: row })
} catch (e) { next(e) }
})
// PUT /api/checkin/habits/:id 编辑
router.put('/habits/:id', async (req, res, next) => {
try {
const userId = req.user.sub
const id = Number(req.params.id)
const exist = await prisma.habit.findFirst({ where: { id, userId } })
if (!exist) return res.status(404).json({ error: '打卡项不存在' })
const { name, period, target, color, icon, note, archived, sortOrder } = req.body || {}
const data = {}
if (name !== undefined) {
const n = String(name).trim()
if (!n) return res.status(400).json({ error: '名称不能为空' })
if (n.length > 20) return res.status(400).json({ error: '名称不能超过 20 个字' })
data.name = n
}
if (period !== undefined) {
if (!PERIODS.includes(period)) return res.status(400).json({ error: '周期类型不合法' })
data.period = period
}
if (target !== undefined) {
const t = Math.floor(Number(target))
if (!Number.isFinite(t) || t < 1) return res.status(400).json({ error: '目标次数需为 ≥1 的整数' })
data.target = t
}
if (color !== undefined) data.color = String(color)
if (icon !== undefined) data.icon = String(icon)
if (note !== undefined) data.note = String(note)
if (archived !== undefined) data.archived = !!archived
if (sortOrder !== undefined) data.sortOrder = Number(sortOrder) || 0
const row = await prisma.habit.update({ where: { id }, data })
res.json({ item: row })
} catch (e) { next(e) }
})
// DELETE /api/checkin/habits/:id?hard=1 默认归档(保留历史), hard=1 物理删除
router.delete('/habits/:id', async (req, res, next) => {
try {
const userId = req.user.sub
const id = Number(req.params.id)
const exist = await prisma.habit.findFirst({ where: { id, userId } })
if (!exist) return res.status(404).json({ error: '打卡项不存在' })
if (req.query.hard === '1') {
await prisma.checkIn.deleteMany({ where: { habitId: id } })
await prisma.habit.delete({ where: { id } })
return res.json({ ok: true, removed: true })
}
const row = await prisma.habit.update({ where: { id }, data: { archived: true } })
res.json({ ok: true, removed: false, item: row })
} catch (e) { next(e) }
})
// ---------- 打卡 / 撤销 ----------
// POST /api/checkin/habits/:id/check { date?, note? } 打一次卡
router.post('/habits/:id/check', async (req, res, next) => {
try {
const userId = req.user.sub
const id = Number(req.params.id)
const habit = await prisma.habit.findFirst({ where: { id, userId } })
if (!habit) return res.status(404).json({ error: '打卡项不存在' })
const today = toDateStr()
const date = normDate((req.body || {}).date)
if (date > today) return res.status(400).json({ error: '不能为未来日期打卡' })
if (date < addDays(today, -BACKFILL_DAYS)) {
return res.status(400).json({ error: `最多只能补打最近 ${BACKFILL_DAYS} 天` })
}
const dayRecords = await prisma.checkIn.findMany({ where: { habitId: id, date }, select: { seq: true } })
const seq = dayRecords.reduce((m, r) => Math.max(m, r.seq), 0) + 1
const note = String((req.body || {}).note || '').slice(0, 200)
await prisma.checkIn.create({
data: { habitId: id, userId, date, seq, note, source: date === today ? 'user' : 'backfill' },
})
const grouped = await loadRecords(userId, [habit], [periodRange(habit.period, date)])
res.json({ item: buildItem(habit, grouped.get(habit.id) || new Map(), date, today), date })
} catch (e) {
// 唯一约束冲突 = 并发重复提交, 视为已打卡
if (e && e.code === 'P2002') return res.status(409).json({ error: '该次打卡已记录' })
next(e)
}
})
// DELETE /api/checkin/habits/:id/check?date= 撤销本周期内最近一次打卡
router.delete('/habits/:id/check', async (req, res, next) => {
try {
const userId = req.user.sub
const id = Number(req.params.id)
const habit = await prisma.habit.findFirst({ where: { id, userId } })
if (!habit) return res.status(404).json({ error: '打卡项不存在' })
const today = toDateStr()
const date = normDate(req.query.date)
const { start, end } = periodRange(habit.period, date)
const last = await prisma.checkIn.findFirst({
where: { habitId: id, date: { gte: start, lte: end } },
orderBy: [{ date: 'desc' }, { seq: 'desc' }],
})
if (!last) return res.status(400).json({ error: '本周期还没有打卡记录' })
await prisma.checkIn.delete({ where: { id: last.id } })
const grouped = await loadRecords(userId, [habit], [{ start, end }])
res.json({ item: buildItem(habit, grouped.get(habit.id) || new Map(), date, today), date })
} catch (e) { next(e) }
})
// ---------- 某日详情 / 月历 ----------
// GET /api/checkin/day?date=YYYY-MM-DD 当天每个打卡项的明细 (含可打卡/只读标记)
router.get('/day', async (req, res, next) => {
try {
const userId = req.user.sub
const today = toDateStr()
const date = normDate(req.query.date)
const habits = await loadActiveHabits(userId)
const ranges = habits.map((h) => periodRange(h.period, date))
const grouped = await loadRecords(userId, habits, ranges.length ? ranges : [{ start: date, end: date }])
const items = habits.map((h) => {
const base = buildItem(h, grouped.get(h.id) || new Map(), date, today)
return { ...base, canCheck: date <= today && date >= addDays(today, -BACKFILL_DAYS) }
})
res.json({ today, date, isToday: date === today, isFuture: date > today, items })
} catch (e) { next(e) }
})
// GET /api/checkin/calendar?month=YYYY-MM 月历: 每天完成情况 + 当月完成率
router.get('/calendar', async (req, res, next) => {
try {
const userId = req.user.sub
const today = toDateStr()
const month = MONTH_RE.test(String(req.query.month || '')) ? String(req.query.month) : today.slice(0, 7)
const start = month + '-01'
const end = addDays(monthStartNext(month), -1)
const habits = await prisma.habit.findMany({ where: { userId }, orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }] })
const active = habits.filter((h) => !h.archived)
const records = await prisma.checkIn.findMany({
where: { userId, date: { gte: start, lte: end } },
select: { habitId: true, date: true },
})
const grouped = groupRecords(records)
const days = []
let expTotal = 0
let doneTotal = 0
eachDay(start, end, (d) => {
let expected = 0
let actual = 0
let over = false
const detail = []
for (const h of active) {
if (toDateStr(h.createdAt) > d) continue // 创建之前的日期不计入期望
const exp = expectedPerDay(h.period, Math.max(1, h.target), d)
const cnt = (grouped.get(h.id) || new Map()).get(d) || 0
expected += exp
actual += Math.min(cnt, exp)
if (cnt > exp) over = true
detail.push({ id: h.id, name: h.name, color: h.color, count: cnt, expected: exp, done: cnt >= exp })
}
const isFuture = d > today
if (!isFuture) { expTotal += expected; doneTotal += actual }
let status = 'empty'
if (expected > 0 && actual >= expected) status = over ? 'over' : 'full'
else if (actual > 0) status = 'partial'
days.push({
date: d, expected, actual, status, isToday: d === today, isFuture,
habits: detail,
})
})
res.json({
today, month, days,
rate: expTotal > 0 ? Math.min(1, doneTotal / expTotal) : 0,
habits: active.map((h) => ({ id: h.id, name: h.name, color: h.color, period: h.period, target: h.target })),
})
} catch (e) { next(e) }
})
function monthStartNext(month) {
const [y, m] = month.split('-').map(Number)
const d = new Date(Date.UTC(y, m, 1)) // m 已 +1 (下月)
return d.toISOString().slice(0, 10)
}
export default router
+313
View File
@@ -0,0 +1,313 @@
import { Router } from 'express'
import { prisma } from '../db.js'
import { authRequired } from '../middleware.js'
const router = Router()
router.use(authRequired)
// 系统预置模板 (builtin): 首次访问模板接口时幂等写入
const BUILTIN_TEMPLATES = [
{
name: '出差',
scene: '出差',
items: [
{ title: '身份证 / 证件' },
{ title: '手机充电器 + 充电宝' },
{ title: '笔记本电脑 + 电源' },
{ title: '耳机' },
{ title: '换洗衣物' },
{ title: '洗漱包' },
{ title: '常用药' },
{ title: '雨伞' },
{ title: '车票 / 机票 / 酒店确认' },
{ title: '工牌 / 名片' },
{ title: '报销单据' },
],
},
{
name: '回老家',
scene: '回老家',
items: [
{ title: '身份证 / 证件' },
{ title: '钥匙' },
{ title: '手机充电器 + 充电宝' },
{ title: '换洗衣物' },
{ title: '洗漱包' },
{ title: '常用药' },
{ title: '给家人的礼物 / 伴手礼' },
{ title: '长辈的药品 / 营养品' },
{ title: '口罩' },
{ title: '现金零钱' },
{ title: '车票 / 加油 / 行程确认' },
{ title: '家里门窗水电检查' },
],
},
]
async function ensureBuiltin() {
const count = await prisma.checkListTemplate.count({ where: { builtin: true } })
if (count >= BUILTIN_TEMPLATES.length) return
for (const t of BUILTIN_TEMPLATES) {
const exist = await prisma.checkListTemplate.findFirst({ where: { builtin: true, name: t.name } })
if (exist) continue
await prisma.checkListTemplate.create({
data: { name: t.name, scene: t.scene, items: t.items, builtin: true, userId: null },
})
}
}
const LIST_INCLUDE = { items: { orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }] } }
function withProgress(list) {
const items = list.items || []
const done = items.filter((i) => i.done).length
return {
...list,
items,
total: items.length,
done,
percent: items.length > 0 ? done / items.length : 0,
completed: items.length > 0 && done === items.length,
}
}
// ---------- 模板 ----------
// GET /api/checklists/templates
router.get('/templates', async (_req, res, next) => {
try {
await ensureBuiltin()
const rows = await prisma.checkListTemplate.findMany({
where: { OR: [{ builtin: true }, { userId: _req.user.sub }] },
orderBy: [{ builtin: 'desc' }, { id: 'asc' }],
})
res.json({ items: rows })
} catch (e) { next(e) }
})
// POST /api/checklists/templates 另存为模板 { name, scene, items: [{title, note}] }
router.post('/templates', async (req, res, next) => {
try {
const userId = req.user.sub
const { name, scene = '', items = [] } = req.body || {}
const cleanName = String(name || '').trim()
if (!cleanName) return res.status(400).json({ error: '模板名称不能为空' })
const list = (Array.isArray(items) ? items : [])
.map((i) => ({ title: String(i?.title || '').trim(), note: String(i?.note || '') }))
.filter((i) => i.title)
if (!list.length) return res.status(400).json({ error: '模板至少需要一个检查项' })
const row = await prisma.checkListTemplate.create({
data: { userId, name: cleanName, scene: String(scene), items: list, builtin: false },
})
res.json({ item: row })
} catch (e) { next(e) }
})
// DELETE /api/checklists/templates/:id
router.delete('/templates/:id', async (req, res, next) => {
try {
const userId = req.user.sub
const id = Number(req.params.id)
const exist = await prisma.checkListTemplate.findFirst({ where: { id, userId } })
if (!exist) return res.status(404).json({ error: '模板不存在或无权删除' })
await prisma.checkListTemplate.delete({ where: { id } })
res.json({ ok: true })
} catch (e) { next(e) }
})
// ---------- 清单 ----------
// GET /api/checklists?archived=0|1
router.get('/', async (req, res, next) => {
try {
const userId = req.user.sub
const archived = String(req.query.archived || '0') === '1'
const rows = await prisma.checkList.findMany({
where: { userId, archived },
include: LIST_INCLUDE,
orderBy: [{ updatedAt: 'desc' }],
})
res.json({ items: rows.map(withProgress) })
} catch (e) { next(e) }
})
// POST /api/checklists { title, scene?, note?, items?: string[] | [{title,note}], templateId? }
router.post('/', async (req, res, next) => {
try {
const userId = req.user.sub
const { title, scene = '', note = '', items = [], templateId } = req.body || {}
const cleanTitle = String(title || '').trim()
if (!cleanTitle) return res.status(400).json({ error: '清单名称不能为空' })
let seed = (Array.isArray(items) ? items : [])
.map((i) => (typeof i === 'string' ? { title: i } : i))
.map((i) => ({ title: String(i?.title || '').trim(), note: String(i?.note || '') }))
.filter((i) => i.title)
if (!seed.length && templateId) {
const tpl = await prisma.checkListTemplate.findFirst({
where: { id: Number(templateId), OR: [{ builtin: true }, { userId }] },
})
if (tpl && Array.isArray(tpl.items)) {
seed = tpl.items.map((i) => ({ title: String(i?.title || '').trim(), note: String(i?.note || '') })).filter((i) => i.title)
}
}
const row = await prisma.checkList.create({
data: {
userId,
title: cleanTitle,
scene: String(scene),
note: String(note),
items: { create: seed.map((i, idx) => ({ title: i.title, note: i.note, sortOrder: idx })) },
},
include: LIST_INCLUDE,
})
res.json({ item: withProgress(row) })
} catch (e) { next(e) }
})
// GET /api/checklists/:id
router.get('/:id', async (req, res, next) => {
try {
const userId = req.user.sub
const id = Number(req.params.id)
const row = await prisma.checkList.findFirst({ where: { id, userId }, include: LIST_INCLUDE })
if (!row) return res.status(404).json({ error: '清单不存在' })
res.json({ item: withProgress(row) })
} catch (e) { next(e) }
})
// PUT /api/checklists/:id { title?, scene?, note?, archived? }
router.put('/:id', async (req, res, next) => {
try {
const userId = req.user.sub
const id = Number(req.params.id)
const exist = await prisma.checkList.findFirst({ where: { id, userId } })
if (!exist) return res.status(404).json({ error: '清单不存在' })
const { title, scene, note, archived } = req.body || {}
const data = {}
if (title !== undefined) {
const t = String(title).trim()
if (!t) return res.status(400).json({ error: '清单名称不能为空' })
data.title = t
}
if (scene !== undefined) data.scene = String(scene)
if (note !== undefined) data.note = String(note)
if (archived !== undefined) data.archived = !!archived
const row = await prisma.checkList.update({ where: { id }, data, include: LIST_INCLUDE })
res.json({ item: withProgress(row) })
} catch (e) { next(e) }
})
// DELETE /api/checklists/:id
router.delete('/:id', async (req, res, next) => {
try {
const userId = req.user.sub
const id = Number(req.params.id)
const exist = await prisma.checkList.findFirst({ where: { id, userId } })
if (!exist) return res.status(404).json({ error: '清单不存在' })
await prisma.checkListItem.deleteMany({ where: { listId: id } })
await prisma.checkList.delete({ where: { id } })
res.json({ ok: true })
} catch (e) { next(e) }
})
// POST /api/checklists/:id/duplicate 复制一份新清单 (勾选状态重置)
router.post('/:id/duplicate', async (req, res, next) => {
try {
const userId = req.user.sub
const id = Number(req.params.id)
const src = await prisma.checkList.findFirst({ where: { id, userId }, include: LIST_INCLUDE })
if (!src) return res.status(404).json({ error: '清单不存在' })
const row = await prisma.checkList.create({
data: {
userId,
title: src.title + ' 副本',
scene: src.scene,
note: src.note,
items: {
create: src.items.map((i, idx) => ({ title: i.title, note: i.note, done: false, sortOrder: idx })),
},
},
include: LIST_INCLUDE,
})
res.json({ item: withProgress(row) })
} catch (e) { next(e) }
})
// ---------- 检查项 ----------
// POST /api/checklists/:id/items { title, note? }
router.post('/:id/items', async (req, res, next) => {
try {
const userId = req.user.sub
const listId = Number(req.params.id)
const list = await prisma.checkList.findFirst({ where: { id: listId, userId } })
if (!list) return res.status(404).json({ error: '清单不存在' })
const title = String((req.body || {}).title || '').trim()
if (!title) return res.status(400).json({ error: '检查项不能为空' })
if (title.length > 40) return res.status(400).json({ error: '检查项不能超过 40 个字' })
const max = await prisma.checkListItem.findFirst({ where: { listId }, orderBy: { sortOrder: 'desc' } })
const row = await prisma.checkListItem.create({
data: { listId, title, note: String((req.body || {}).note || ''), sortOrder: (max?.sortOrder || 0) + 1 },
})
res.json({ item: row })
} catch (e) { next(e) }
})
// PUT /api/checklists/items/:id { title?, note?, done?, sortOrder? }
router.put('/items/:id', async (req, res, next) => {
try {
const userId = req.user.sub
const id = Number(req.params.id)
const exist = await prisma.checkListItem.findFirst({ where: { id, list: { userId } } })
if (!exist) return res.status(404).json({ error: '检查项不存在' })
const { title, note, done, sortOrder } = req.body || {}
const data = {}
if (title !== undefined) {
const t = String(title).trim()
if (!t) return res.status(400).json({ error: '检查项不能为空' })
data.title = t
}
if (note !== undefined) data.note = String(note)
if (sortOrder !== undefined) data.sortOrder = Number(sortOrder) || 0
if (done !== undefined) {
data.done = !!done
data.doneAt = done ? new Date() : null
}
const row = await prisma.checkListItem.update({ where: { id }, data })
res.json({ item: row })
} catch (e) { next(e) }
})
// DELETE /api/checklists/items/:id
router.delete('/items/:id', async (req, res, next) => {
try {
const userId = req.user.sub
const id = Number(req.params.id)
const exist = await prisma.checkListItem.findFirst({ where: { id, list: { userId } } })
if (!exist) return res.status(404).json({ error: '检查项不存在' })
await prisma.checkListItem.delete({ where: { id } })
res.json({ ok: true })
} catch (e) { next(e) }
})
// POST /api/checklists/:id/check-all { done: boolean } 一键全选 / 全不选
router.post('/:id/check-all', async (req, res, next) => {
try {
const userId = req.user.sub
const listId = Number(req.params.id)
const list = await prisma.checkList.findFirst({ where: { id: listId, userId } })
if (!list) return res.status(404).json({ error: '清单不存在' })
const done = !!(req.body || {}).done
await prisma.checkListItem.updateMany({
where: { listId },
data: { done, doneAt: done ? new Date() : null },
})
const row = await prisma.checkList.findFirst({ where: { id: listId }, include: LIST_INCLUDE })
res.json({ item: withProgress(row) })
} catch (e) { next(e) }
})
export default router
+3
View File
@@ -7,6 +7,7 @@ import MacroPage from './pages/MacroPage.jsx'
import MonitorPage from './pages/MonitorPage.jsx'
import ServerDetail from './pages/monitor/ServerDetail.jsx'
import MePage from './pages/MePage.jsx'
import CheckinPage from './pages/checkin/CheckinPage.jsx'
import { useAuth } from './auth/AuthContext.jsx'
// 已登录访问 /login 自动回首页
@@ -36,6 +37,8 @@ export default function App() {
<Route path="/macro/*" element={<MacroPage />} />
<Route path="/monitor" element={<MonitorPage />} />
<Route path="/monitor/server/:uuid" element={<ServerDetail />} />
{/* V0.2 打卡 / 日历 / 清单 */}
<Route path="/checkin" element={<CheckinPage />} />
<Route path="/me" element={<MePage />} />
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
+39
View File
@@ -0,0 +1,39 @@
import { api } from './client.js'
const q = (params) => {
const s = new URLSearchParams()
Object.entries(params || {}).forEach(([k, v]) => {
if (v !== undefined && v !== null && v !== '') s.set(k, v)
})
const str = s.toString()
return str ? '?' + str : ''
}
// 打卡 / 日历
export const checkinApi = {
habits: (date) => api('/checkin/habits' + q({ date })),
day: (date) => api('/checkin/day' + q({ date })),
calendar: (month) => api('/checkin/calendar' + q({ month })),
createHabit: (body) => api('/checkin/habits', { method: 'POST', body }),
updateHabit: (id, body) => api(`/checkin/habits/${id}`, { method: 'PUT', body }),
deleteHabit: (id, hard) => api(`/checkin/habits/${id}${hard ? '?hard=1' : ''}`, { method: 'DELETE' }),
check: (id, date, note) => api(`/checkin/habits/${id}/check`, { method: 'POST', body: { date, note } }),
uncheck: (id, date) => api(`/checkin/habits/${id}/check` + q({ date }), { method: 'DELETE' }),
}
// 检查清单
export const checklistApi = {
lists: (archived) => api('/checklists' + q({ archived: archived ? 1 : 0 })),
list: (id) => api(`/checklists/${id}`),
create: (body) => api('/checklists', { method: 'POST', body }),
update: (id, body) => api(`/checklists/${id}`, { method: 'PUT', body }),
remove: (id) => api(`/checklists/${id}`, { method: 'DELETE' }),
duplicate: (id) => api(`/checklists/${id}/duplicate`, { method: 'POST', body: {} }),
addItem: (id, body) => api(`/checklists/${id}/items`, { method: 'POST', body }),
updateItem: (itemId, body) => api(`/checklists/items/${itemId}`, { method: 'PUT', body }),
removeItem: (itemId) => api(`/checklists/items/${itemId}`, { method: 'DELETE' }),
checkAll: (id, done) => api(`/checklists/${id}/check-all`, { method: 'POST', body: { done } }),
templates: () => api('/checklists/templates'),
createTemplate: (body) => api('/checklists/templates', { method: 'POST', body }),
removeTemplate: (id) => api(`/checklists/templates/${id}`, { method: 'DELETE' }),
}
+1
View File
@@ -8,6 +8,7 @@ const TABS = [
{ key: '/', name: 'home', label: '首页' },
{ key: '/macro', name: 'macro', label: '宏观' },
{ key: '/monitor', name: 'monitor', label: '监控' },
{ key: '/checkin', name: 'checkin', label: '打卡' },
{ key: '/me', name: 'me', label: '我' },
]
+139
View File
@@ -0,0 +1,139 @@
import { useEffect, useRef, useState } from 'react'
// 环形进度组件 (F-CK-03/04/05, U-01)
// props:
// percent: 0~1 完成度
// size: 外框尺寸(px)
// stroke: 环宽(px)
// color: 进度色
// completed: 是否达标 (达标时显示对勾而非数字)
// done/target: 中心文案
// onClick: 可选, 点击整个环触发 (用于"点环即打卡")
export default function RingProgress({
percent = 0,
size = 58,
stroke = 6,
color = '#1677ff',
completed = false,
done = 0,
target = 1,
over = 0,
children,
onClick,
disabled = false,
}) {
const p = Math.max(0, Math.min(1, Number(percent) || 0))
const r = (size - stroke) / 2
const c = 2 * Math.PI * r
const offset = c * (1 - p)
return (
<div
className={'ring-wrap' + (onClick ? ' clickable' : '') + (disabled ? ' disabled' : '')}
style={{ width: size, height: size }}
onClick={disabled ? undefined : onClick}
>
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`} className="ring-svg">
{/* 背景环 */}
<circle
cx={size / 2} cy={size / 2} r={r}
fill="none" stroke="#ececf0" strokeWidth={stroke}
/>
{/* 进度环: 从 12 点方向顺时针 */}
<circle
className="ring-bar"
cx={size / 2} cy={size / 2} r={r}
fill="none" stroke={color} strokeWidth={stroke} strokeLinecap="round"
strokeDasharray={c} strokeDashoffset={offset}
transform={`rotate(-90 ${size / 2} ${size / 2})`}
/>
</svg>
<div className="ring-center">
{children !== undefined ? children : (
completed ? (
<svg className="ring-check" width="20" height="20" viewBox="0 0 24 24" fill="none">
<path d="M4 12.5l5 5L20 6.5" stroke={color} strokeWidth="3" strokeLinecap="round" strokeLinejoin="round" />
</svg>
) : (
<span className="ring-num">
{done}
<em>/{target}</em>
</span>
)
)}
</div>
{over > 0 && <span className="ring-over">+{over}</span>}
</div>
)
}
// 完成特效 (F-CK-07, U-02/U-03/U-07)
// props: fire 计数器(每次 +1 触发一次), color 主色
// 尊重"特效开关": localStorage iboard_celebrate === '0' 时不播放
export function Celebrate({ fire = 0, color = '#1677ff' }) {
const [burst, setBurst] = useState(0)
const [visible, setVisible] = useState(false)
const prev = useRef(fire)
const timer = useRef(null)
useEffect(() => {
if (fire === prev.current) return
prev.current = fire
if (fire <= 0) return
if (!effectsEnabled()) return
setBurst((n) => n + 1)
setVisible(true)
haptic()
clearTimeout(timer.current)
timer.current = setTimeout(() => setVisible(false), 900)
return () => clearTimeout(timer.current)
}, [fire])
if (!visible) return null
const dots = Array.from({ length: 14 }, (_, i) => i)
return (
<div className="celebrate" key={burst}>
<span className="celebrate-ring" style={{ borderColor: color }} />
{dots.map((i) => {
const angle = (360 / dots.length) * i + (i % 2 ? 8 : -8)
const dist = 26 + (i % 4) * 8
return (
<i
key={i}
style={{
background: i % 3 === 0 ? '#ffc53d' : color,
'--a': angle + 'deg',
'--d': dist + 'px',
animationDelay: (i % 3) * 40 + 'ms',
}}
/>
)
})}
</div>
)
}
// 特效全局开关 (默认开)
export function effectsEnabled() {
try {
if (localStorage.getItem('iboard_celebrate') === '0') return false
} catch { /* ignore */ }
// 系统"减弱动效"偏好时降级
if (typeof window !== 'undefined' && window.matchMedia) {
try {
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return false
} catch { /* ignore */ }
}
return true
}
export function setEffectsEnabled(v) {
try { localStorage.setItem('iboard_celebrate', v ? '1' : '0') } catch { /* ignore */ }
}
// 轻量触感反馈 (Web Vibration API; Tauri Android WebView 支持)
export function haptic(ms = 18) {
try {
if (typeof navigator !== 'undefined' && navigator.vibrate) navigator.vibrate(ms)
} catch { /* ignore */ }
}
+9
View File
@@ -22,6 +22,15 @@ const ICONS = {
<path d="M3 12h4l2-5 4 10 2-5h6" fill={active ? 'currentColor' : 'none'} fillOpacity={active ? 0.12 : 0} />
</svg>
),
// 打卡: 日历 + 对勾
checkin: (active) => (
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={active ? 2.2 : 1.8} strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="5" width="18" height="16" rx="2" fill={active ? 'currentColor' : 'none'} fillOpacity={active ? 0.12 : 0} />
<path d="M3 10h18" />
<path d="M8 3v4M16 3v4" />
<path d="M9 15.5l2.2 2.2L15.5 13" />
</svg>
),
me: (active) => (
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={active ? 2.2 : 1.8} strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="8" r="4" fill={active ? 'currentColor' : 'none'} fillOpacity={active ? 0.15 : 0} />
+173
View File
@@ -0,0 +1,173 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { Button, DotLoading, Empty, Segmented, Toast } from 'antd-mobile'
import { Celebrate } from '../../components/RingProgress.jsx'
import { checkinApi } from '../../api/checkin.js'
import useRefreshOnVisible from '../../hooks/useRefreshOnVisible.js'
import {
toDateStr, addDays, addMonths, monthGrid, weekdayLabels, dayLabel, fmtMonth,
} from './date.js'
// 日历视图: 月历网格 + 今天/昨天/明天 三视图 + 日期标记 (F-CL-*)
export default function CalendarView({ date, onDateChange }) {
const today = toDateStr()
const [month, setMonth] = useState(() => date.slice(0, 7))
const [cal, setCal] = useState(null) // 月历数据
const [detail, setDetail] = useState(null) // 选中日详情
const [err, setErr] = useState('')
const [bursts, setBursts] = useState({})
const loadCal = useCallback((silent) => {
if (!silent) setErr('')
checkinApi.calendar(month)
.then(setCal)
.catch((e) => { if (!silent) setErr(e.message) })
}, [month])
const loadDetail = useCallback((silent) => {
checkinApi.day(date).then(setDetail).catch(() => {})
}, [date])
useEffect(() => { loadCal(false) }, [loadCal])
useEffect(() => { setDetail(null); loadDetail(false) }, [loadDetail])
useRefreshOnVisible(() => { loadCal(true); loadDetail(true) })
// 切换日期时, 若跨月则同步月份
useEffect(() => {
const m = date.slice(0, 7)
setMonth((cur) => (cur === m ? cur : m))
}, [date])
const days = useMemo(() => monthGrid(month), [month])
const byDate = useMemo(() => {
const map = new Map()
;(cal?.days || []).forEach((d) => map.set(d.date, d))
return map
}, [cal])
const quickValue = useMemo(() => {
if (date === today) return 0
if (date === addDays(today, -1)) return -1
if (date === addDays(today, 1)) return 1
return null
}, [date, today])
async function checkFromCalendar(item) {
if (!item.canCheck) return
const snapshot = detail
setDetail((d) => ({
...d,
items: d.items.map((x) => {
if (x.id !== item.id) return x
const done = x.done + 1
const countOnDate = (x.countOnDate || 0) + 1
return {
...x, done, countOnDate,
percent: Math.min(1, done / x.target),
completed: done >= x.target,
over: Math.max(0, done - x.target),
dayDone: countOnDate >= x.expectedPerDay,
}
}),
}))
try {
const res = await checkinApi.check(item.id, date)
const fresh = await checkinApi.day(date)
setDetail(fresh)
if (res.item.completed) setBursts((m) => ({ ...m, [item.id]: (m[item.id] || 0) + 1 }))
loadCal(true)
} catch (e) {
setDetail(snapshot)
Toast.show({ content: e.message || '打卡失败' })
}
}
const items = detail?.items || []
const isFuture = !!detail?.isFuture
return (
<div className="calendar-view">
<div className="cal-month-bar">
<span className="cal-arrow" onClick={() => setMonth((m) => addMonths(m, -1))}>‹</span>
<span className="cal-month">{fmtMonth(month)}</span>
<span className="cal-arrow" onClick={() => setMonth((m) => addMonths(m, 1))}>›</span>
<span className="cal-today-btn" onClick={() => onDateChange(today)}>回到今天</span>
</div>
<div className="cal-quick">
<Segmented
options={[
{ label: '昨天', value: -1 },
{ label: '今天', value: 0 },
{ label: '明天', value: 1 },
]}
value={quickValue}
onChange={(v) => onDateChange(addDays(today, Number(v)))}
/>
</div>
{err && <div className="error-banner">{err}<span className="retry-link" onClick={() => loadCal(false)}>重试</span></div>}
<div className="cal-grid">
<div className="cal-weekday">
{weekdayLabels().map((w) => <span key={w}>{w}</span>)}
</div>
<div className="cal-days">
{days.map((d) => {
const info = byDate.get(d)
const inMonth = d.slice(0, 7) === month
const status = info?.status || 'empty'
return (
<div
key={d}
className={
'cal-day' + (inMonth ? '' : ' out') + (d === date ? ' selected' : '') +
(info?.isToday ? ' today' : '') + (info?.isFuture ? ' future' : '')
}
onClick={() => onDateChange(d)}
>
<span className="cal-day-num">{Number(d.slice(8, 10))}</span>
{inMonth && status !== 'empty' && (
<span className={'cal-dot ' + status} />
)}
</div>
)
})}
</div>
</div>
{cal && (
<div className="cal-rate">本月完成率 <strong>{Math.round((cal.rate || 0) * 100)}%</strong></div>
)}
<div className="cal-detail">
<div className="cal-detail-title">
{dayLabel(date, today)} · {date}
{isFuture && <span className="habit-badge">计划(不可打卡)</span>}
</div>
{detail === null && <div className="monitor-loading"><DotLoading /> 加载中...</div>}
{detail !== null && items.length === 0 && <Empty description="当天没有打卡项" />}
{items.map((item) => (
<div key={item.id} className={'cal-item' + (item.completed ? ' done' : '')}>
<span className="cal-item-dot" style={{ background: item.color }} />
<span className="cal-item-name">{item.name}</span>
<span className="cal-item-meta">
{item.periodLabel} {item.target} 次 · 当日 {item.countOnDate || 0}/{item.expectedPerDay}
</span>
<span className="cal-item-right">
{item.canCheck ? (
<Button size="mini" color={item.dayDone ? 'default' : 'primary'} onClick={() => checkFromCalendar(item)}>
{item.dayDone ? '已达标' : '打卡'}
</Button>
) : (
<span className="cal-item-plan">{item.dayDone ? '计划' : '待办'}</span>
)}
</span>
<Celebrate fire={bursts[item.id] || 0} color={item.color} />
</div>
))}
</div>
</div>
)
}
+28
View File
@@ -0,0 +1,28 @@
import { useState } from 'react'
import { NavBar } from 'antd-mobile'
import SwipeTabs from '../../components/SwipeTabs.jsx'
import HabitView from './HabitView.jsx'
import CalendarView from './CalendarView.jsx'
import ListView from './ListView.jsx'
import { toDateStr } from './date.js'
// 打卡页: 一个页面三个视图 —— 打卡(环形进度) / 日历(今天·昨天·明天) / 清单(场景检查)
export default function CheckinPage() {
const [active, setActive] = useState('habit')
const [date, setDate] = useState(() => toDateStr())
const tabs = [
{ key: 'habit', label: '打卡', render: () => <HabitView date={date} /> },
{ key: 'calendar', label: '日历', render: () => <CalendarView date={date} onDateChange={setDate} /> },
{ key: 'list', label: '清单', render: () => <ListView /> },
]
return (
<div className="page-with-navbar">
<NavBar back={null}>打卡</NavBar>
<div className="checkin-content">
<SwipeTabs tabs={tabs} active={active} onChange={setActive} />
</div>
</div>
)
}
+290
View File
@@ -0,0 +1,290 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import {
Button, Dialog, DotLoading, Empty, Input, Popup, Selector, Stepper, Toast,
} from 'antd-mobile'
import RingProgress, { Celebrate, effectsEnabled, setEffectsEnabled } from '../../components/RingProgress.jsx'
import { checkinApi } from '../../api/checkin.js'
import useRefreshOnVisible from '../../hooks/useRefreshOnVisible.js'
import { toDateStr, dayLabel } from './date.js'
const PERIOD_OPTIONS = [
{ label: '每天', value: 'day' },
{ label: '每周', value: 'week' },
{ label: '每月', value: 'month' },
{ label: '每年', value: 'year' },
]
const COLORS = ['#1677ff', '#00b578', '#ff8f1f', '#ff3141', '#a855f7', '#14b8a6', '#f43f9e']
const EMPTY_FORM = { id: null, name: '', period: 'day', target: 1, color: COLORS[0], note: '' }
// 打卡视图: 今日待办列表 + 环形进度 + 打卡/撤销 + 新建/编辑
export default function HabitView({ date, onDateChange }) {
const [data, setData] = useState(null) // { today, date, items, summary }
const [err, setErr] = useState('')
const [bursts, setBursts] = useState({}) // { habitId: 触发计数 }
const [form, setForm] = useState(EMPTY_FORM)
const [showForm, setShowForm] = useState(false)
const [effects, setEffects] = useState(effectsEnabled())
const load = useCallback((silent) => {
if (!silent) setErr('')
checkinApi.habits(date)
.then((d) => setData(d))
.catch((e) => { if (!silent) setErr(e.message) })
}, [date])
useEffect(() => { load(false) }, [load])
useRefreshOnVisible(() => load(true))
const items = useMemo(() => {
const list = data?.items || []
// 未完成在前, 已完成在后 (F-CK-16 默认排序)
return [...list].sort((a, b) => Number(a.completed) - Number(b.completed) || a.id - b.id)
}, [data])
const fireBurst = (id) => setBursts((m) => ({ ...m, [id]: (m[id] || 0) + 1 }))
// 乐观更新: 先本地 +1, 成功用服务端结果覆盖, 失败回滚 (F-SY-06/E-02)
async function doCheck(item) {
const before = data
const patch = (it) => {
const done = it.done + 1
return {
...it,
done,
remaining: Math.max(0, it.target - done),
percent: Math.min(1, done / it.target),
completed: done >= it.target,
over: Math.max(0, done - it.target),
countOnDate: (it.countOnDate || 0) + 1,
dayDone: (it.countOnDate || 0) + 1 >= it.expectedPerDay,
}
}
setData((d) => ({ ...d, items: d.items.map((x) => (x.id === item.id ? patch(x) : x)) }))
try {
const res = await checkinApi.check(item.id, date)
setData((d) => ({ ...d, items: d.items.map((x) => (x.id === item.id ? res.item : x)) }))
if (res.item.completed) fireBurst(item.id)
} catch (e) {
setData(before)
Toast.show({ content: e.message || '打卡失败' })
}
}
async function doUncheck(item) {
const before = data
setData((d) => ({
...d,
items: d.items.map((x) => {
if (x.id !== item.id) return x
const done = Math.max(0, x.done - 1)
return {
...x,
done,
remaining: Math.max(0, x.target - done),
percent: Math.min(1, done / x.target),
completed: done >= x.target,
over: Math.max(0, done - x.target),
countOnDate: Math.max(0, (x.countOnDate || 0) - 1),
}
}),
}))
try {
const res = await checkinApi.uncheck(item.id, date)
setData((d) => ({ ...d, items: d.items.map((x) => (x.id === item.id ? res.item : x)) }))
} catch (e) {
setData(before)
Toast.show({ content: e.message || '撤销失败' })
}
}
async function submitForm() {
const name = String(form.name || '').trim()
if (!name) return Toast.show({ content: '请填写名称' })
if (name.length > 20) return Toast.show({ content: '名称不能超过 20 个字' })
const payload = { name, period: form.period, target: Number(form.target) || 1, color: form.color, note: form.note }
try {
if (form.id) await checkinApi.updateHabit(form.id, payload)
else await checkinApi.createHabit(payload)
setShowForm(false)
setForm(EMPTY_FORM)
load(true)
} catch (e) {
Toast.show({ content: e.message || '保存失败' })
}
}
async function removeHabit(item) {
const ok = await Dialog.confirm({
content: `归档「${item.name}」?归档后不再出现在待办,历史记录保留。`,
confirmText: '归档',
cancelText: '取消',
})
if (!ok) return
try {
await checkinApi.deleteHabit(item.id)
Toast.show({ content: '已归档' })
load(true)
} catch (e) {
Toast.show({ content: e.message || '操作失败' })
}
}
const summary = data?.summary || { total: 0, done: 0 }
const isToday = data ? data.date === data.today : true
return (
<div className="habit-view">
<div className="habit-head">
<div className="habit-head-left">
<div className="habit-date">{dayLabel(date, data?.today || toDateStr())}</div>
<div className="habit-summary">
已完成 <strong>{summary.done}</strong> / {summary.total}
{!isToday && <span className="habit-badge">非今天</span>}
</div>
</div>
<div className="habit-head-right">
<Button size="mini" fill="outline" color="primary" onClick={() => setEffects((v) => {
const next = !v
setEffectsEnabled(next)
return next
})}>
{effects ? '特效开' : '特效关'}
</Button>
<Button size="mini" color="primary" onClick={() => { setForm(EMPTY_FORM); setShowForm(true) }}>
+ 新建
</Button>
</div>
</div>
{err && (
<div className="error-banner">
{err}
<span className="retry-link" onClick={() => load(false)}>重试</span>
</div>
)}
{data === null && !err && <div className="monitor-loading"><DotLoading /> 加载中...</div>}
{data !== null && !err && items.length === 0 && (
<div className="checkin-empty">
<Empty description="还没有打卡项" />
<Button color="primary" size="small" onClick={() => { setForm(EMPTY_FORM); setShowForm(true) }}>
创建第一个打卡项
</Button>
</div>
)}
<div className="habit-list">
{items.map((item) => (
<div key={item.id} className={'habit-card' + (item.completed ? ' done' : '')}>
<RingProgress
percent={item.percent}
done={item.done}
target={item.target}
over={item.over}
completed={item.completed}
color={item.color}
onClick={() => doCheck(item)}
/>
<div className="habit-main">
<div className="habit-name">{item.name}</div>
<div className="habit-meta">
{item.periodLabel} {item.target} 次
{item.streak > 0 && <span className="habit-streak">连续 {item.streak}</span>}
</div>
{item.note && <div className="habit-note">{item.note}</div>}
<div className="habit-range">
{item.periodStart === item.periodEnd
? '今日'
: `${item.periodStart.slice(5)} ~ ${item.periodEnd.slice(5)}`}
{item.over > 0 && <span className="habit-over">超额 {item.over}</span>}
</div>
</div>
<div className="habit-actions">
<Button
size="mini"
color={item.completed ? 'default' : 'primary'}
onClick={() => doCheck(item)}
>
{item.completed ? '再打一次' : '打卡'}
</Button>
</div>
<div className="habit-foot">
{item.done > 0 && (
<span className="habit-link" onClick={() => doUncheck(item)}>撤销</span>
)}
<span
className="habit-link"
onClick={() => {
setForm({ id: item.id, name: item.name, period: item.period, target: item.target, color: item.color, note: item.note })
setShowForm(true)
}}
>编辑</span>
<span className="habit-link danger" onClick={() => removeHabit(item)}>归档</span>
</div>
<Celebrate fire={bursts[item.id] || 0} color={item.color} />
</div>
))}
</div>
{/* 新建 / 编辑 */}
<Popup
visible={showForm}
onMaskClick={() => setShowForm(false)}
bodyStyle={{ borderTopLeftRadius: 12, borderTopRightRadius: 12, paddingBottom: 16 }}
>
<div className="form-popup">
<div className="form-title">{form.id ? '编辑打卡项' : '新建打卡项'}</div>
<div className="form-field">
<label>名称</label>
<Input
placeholder="例如:喝水 / 健身 / 读书"
value={form.name}
maxLength={20}
onChange={(v) => setForm((f) => ({ ...f, name: v }))}
/>
</div>
<div className="form-field">
<label>周期</label>
<Selector
options={PERIOD_OPTIONS}
value={[form.period]}
onChange={(v) => setForm((f) => ({ ...f, period: v[0] }))}
/>
</div>
<div className="form-field">
<label>目标次数(周期内)</label>
<Stepper min={1} max={999} value={form.target} onChange={(v) => setForm((f) => ({ ...f, target: v }))} />
</div>
<div className="form-field">
<label>颜色</label>
<div className="color-row">
{COLORS.map((c) => (
<span
key={c}
className={'color-dot' + (form.color === c ? ' active' : '')}
style={{ background: c }}
onClick={() => setForm((f) => ({ ...f, color: c }))}
/>
))}
</div>
</div>
<div className="form-field">
<label>备注(可选)</label>
<Input
placeholder="补充说明"
value={form.note}
onChange={(v) => setForm((f) => ({ ...f, note: v }))}
/>
</div>
<div className="form-actions">
<Button block color="primary" onClick={submitForm}>保存</Button>
<Button block fill="outline" onClick={() => setShowForm(false)} style={{ marginTop: 8 }}>取消</Button>
</div>
</div>
</Popup>
</div>
)
}
+291
View File
@@ -0,0 +1,291 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import {
Button, Dialog, DotLoading, Empty, Input, Popup, Segmented, Selector, Toast,
} from 'antd-mobile'
import { Celebrate } from '../../components/RingProgress.jsx'
import { checklistApi } from '../../api/checkin.js'
import useRefreshOnVisible from '../../hooks/useRefreshOnVisible.js'
const SCENES = ['出差', '回老家', '其他']
// 清单视图: 出差/回老家等场景的一次性检查清单 (F-LS-*)
export default function ListView() {
const [archived, setArchived] = useState(false)
const [lists, setLists] = useState(null)
const [err, setErr] = useState('')
const [openId, setOpenId] = useState(null)
const [newItem, setNewItem] = useState({})
const [bursts, setBursts] = useState({})
const [templates, setTemplates] = useState([])
const [showForm, setShowForm] = useState(false)
const [form, setForm] = useState({ title: '', scene: '出差', templateId: '' })
const load = useCallback((silent) => {
if (!silent) setErr('')
checklistApi.lists(archived)
.then((d) => setLists(d.items || []))
.catch((e) => { if (!silent) setErr(e.message) })
}, [archived])
useEffect(() => { setLists(null); load(false) }, [load])
useRefreshOnVisible(() => load(true))
useEffect(() => {
checklistApi.templates().then((d) => setTemplates(d.items || [])).catch(() => {})
}, [])
const withProgress = useCallback((list) => {
const items = list.items || []
const done = items.filter((i) => i.done).length
return {
...list,
total: items.length,
done,
percent: items.length > 0 ? done / items.length : 0,
completed: items.length > 0 && done === items.length,
}
}, [])
const patchList = (id, updater) =>
setLists((prev) => (prev || []).map((l) => (l.id === id ? updater(l) : l)))
// 勾选: 乐观更新 + 服务端确认 (F-LS-03, F-SY-06)
async function toggleItem(listId, item) {
const snapshot = lists
const next = !item.done
patchList(listId, (l) => withProgress({
...l,
items: l.items.map((i) => (i.id === item.id ? { ...i, done: next } : i)),
}))
try {
await checklistApi.updateItem(item.id, { done: next })
const l = (lists || []).find((x) => x.id === listId)
const updated = withProgress({
...l,
items: l.items.map((i) => (i.id === item.id ? { ...i, done: next } : i)),
})
if (updated.completed) setBursts((m) => ({ ...m, [listId]: (m[listId] || 0) + 1 }))
} catch (e) {
setLists(snapshot)
Toast.show({ content: e.message || '更新失败' })
}
}
async function addItem(listId, title) {
const t = String(title || '').trim()
if (!t) return
try {
const { item } = await checklistApi.addItem(listId, { title: t })
patchList(listId, (l) => withProgress({ ...l, items: [...l.items, item] }))
setNewItem((m) => ({ ...m, [listId]: '' }))
} catch (e) {
Toast.show({ content: e.message || '添加失败' })
}
}
async function removeItem(listId, itemId) {
const snapshot = lists
patchList(listId, (l) => withProgress({ ...l, items: l.items.filter((i) => i.id !== itemId) }))
try {
await checklistApi.removeItem(itemId)
} catch (e) {
setLists(snapshot)
Toast.show({ content: e.message || '删除失败' })
}
}
async function checkAll(listId, done) {
const snapshot = lists
patchList(listId, (l) => withProgress({ ...l, items: l.items.map((i) => ({ ...i, done })) }))
try {
const { item } = await checklistApi.checkAll(listId, done)
patchList(listId, () => withProgress(item))
if (item.completed) setBursts((m) => ({ ...m, [listId]: (m[listId] || 0) + 1 }))
} catch (e) {
setLists(snapshot)
Toast.show({ content: e.message || '操作失败' })
}
}
async function createList() {
const title = String(form.title || '').trim()
if (!title) return Toast.show({ content: '请填写清单名称' })
try {
await checklistApi.create({ title, scene: form.scene, templateId: form.templateId || undefined })
setShowForm(false)
setForm({ title: '', scene: '出差', templateId: '' })
load(true)
} catch (e) {
Toast.show({ content: e.message || '创建失败' })
}
}
async function duplicate(list) {
try {
await checklistApi.duplicate(list.id)
Toast.show({ content: '已复制为新清单' })
load(true)
} catch (e) {
Toast.show({ content: e.message || '复制失败' })
}
}
async function saveAsTemplate(list) {
try {
await checklistApi.createTemplate({
name: list.title,
scene: list.scene || '其他',
items: (list.items || []).map((i) => ({ title: i.title, note: i.note })),
})
Toast.show({ content: '已保存为模板' })
const d = await checklistApi.templates()
setTemplates(d.items || [])
} catch (e) {
Toast.show({ content: e.message || '保存失败' })
}
}
async function toggleArchive(list) {
try {
await checklistApi.update(list.id, { archived: !list.archived })
Toast.show({ content: list.archived ? '已恢复到进行中' : '已归档' })
load(true)
} catch (e) {
Toast.show({ content: e.message || '操作失败' })
}
}
async function removeList(list) {
const ok = await Dialog.confirm({ content: `删除「${list.title}」?`, confirmText: '删除', cancelText: '取消' })
if (!ok) return
try {
await checklistApi.remove(list.id)
load(true)
} catch (e) {
Toast.show({ content: e.message || '删除失败' })
}
}
const sorted = useMemo(() => lists || [], [lists])
return (
<div className="list-view">
<div className="list-view-head">
<Segmented
options={[{ label: '进行中', value: 'active' }, { label: '已归档', value: 'archived' }]}
value={archived ? 'archived' : 'active'}
onChange={(v) => setArchived(v === 'archived')}
/>
<Button size="mini" color="primary" onClick={() => setShowForm(true)}>+ 新建</Button>
</div>
{err && <div className="error-banner">{err}<span className="retry-link" onClick={() => load(false)}>重试</span></div>}
{lists === null && !err && <div className="monitor-loading"><DotLoading /> 加载中...</div>}
{lists !== null && !err && sorted.length === 0 && (
<Empty description={archived ? '暂无归档清单' : '还没有检查清单'} />
)}
<div className="checklist-wrap">
{sorted.map((list) => {
const open = openId === list.id
return (
<div key={list.id} className={'checklist-card' + (list.completed ? ' done' : '')}>
<div className="checklist-head" onClick={() => setOpenId(open ? null : list.id)}>
<div className="checklist-title-row">
<span className="checklist-title">{list.title}</span>
{list.scene && <span className="checklist-scene">{list.scene}</span>}
</div>
<div className="checklist-progress-row">
<div className="checklist-bar">
<div
className="checklist-bar-fill"
style={{ width: Math.round((list.percent || 0) * 100) + '%' }}
/>
</div>
<span className="checklist-count">{list.done}/{list.total}</span>
<span className={'checklist-arrow' + (open ? ' open' : '')}>›</span>
</div>
</div>
{open && (
<div className="checklist-body">
{(list.items || []).map((item) => (
<div key={item.id} className={'checklist-item' + (item.done ? ' done' : '')}>
<span
className="check-box"
onClick={(e) => { e.stopPropagation(); toggleItem(list.id, item) }}
>
<svg viewBox="0 0 24 24" width="14" height="14" fill="none">
<path d="M4 12.5l5 5L20 6.5" stroke="#fff" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</span>
<span className="checklist-item-title" onClick={() => toggleItem(list.id, item)}>
{item.title}
{item.note && <em>{item.note}</em>}
</span>
<span className="checklist-item-del" onClick={() => removeItem(list.id, item.id)}>×</span>
</div>
))}
<div className="checklist-add">
<Input
placeholder="添加检查项"
value={newItem[list.id] || ''}
onChange={(v) => setNewItem((m) => ({ ...m, [list.id]: v }))}
onEnterPress={() => addItem(list.id, newItem[list.id])}
/>
<Button size="mini" color="primary" onClick={() => addItem(list.id, newItem[list.id])}>添加</Button>
</div>
<div className="checklist-actions">
<span className="habit-link" onClick={() => checkAll(list.id, true)}>全选</span>
<span className="habit-link" onClick={() => checkAll(list.id, false)}>全不选</span>
<span className="habit-link" onClick={() => duplicate(list)}>复制</span>
<span className="habit-link" onClick={() => saveAsTemplate(list)}>存为模板</span>
<span className="habit-link" onClick={() => toggleArchive(list)}>{list.archived ? '恢复' : '归档'}</span>
<span className="habit-link danger" onClick={() => removeList(list)}>删除</span>
</div>
</div>
)}
<Celebrate fire={bursts[list.id] || 0} color="#00b578" />
</div>
)
})}
</div>
<Popup
visible={showForm}
onMaskClick={() => setShowForm(false)}
bodyStyle={{ borderTopLeftRadius: 12, borderTopRightRadius: 12, paddingBottom: 16 }}
>
<div className="form-popup">
<div className="form-title">新建检查清单</div>
<div className="form-field">
<label>名称</label>
<Input placeholder="例如:出差 · 杭州" value={form.title} onChange={(v) => setForm((f) => ({ ...f, title: v }))} />
</div>
<div className="form-field">
<label>场景</label>
<Selector
options={SCENES.map((s) => ({ label: s, value: s }))}
value={[form.scene]}
onChange={(v) => setForm((f) => ({ ...f, scene: v[0] }))}
/>
</div>
<div className="form-field">
<label>从模板生成(可选)</label>
<Selector
options={[{ label: '不使用模板', value: '' }, ...templates.map((t) => ({ label: t.name, value: String(t.id) }))]}
value={[form.templateId]}
onChange={(v) => setForm((f) => ({ ...f, templateId: v[0] }))}
/>
</div>
<div className="form-actions">
<Button block color="primary" onClick={createList}>创建</Button>
<Button block fill="outline" onClick={() => setShowForm(false)} style={{ marginTop: 8 }}>取消</Button>
</div>
</div>
</Popup>
</div>
)
}
+88
View File
@@ -0,0 +1,88 @@
// 前端日期/周期工具: 与 server/checkin/period.js 保持同一套口径 (Asia/Shanghai)
export const TZ = 'Asia/Shanghai'
const FMT = new Intl.DateTimeFormat('en-CA', {
timeZone: TZ, year: 'numeric', month: '2-digit', day: '2-digit',
})
/** 任意时间 -> 本地日历日 "YYYY-MM-DD" */
export function toDateStr(d = new Date()) {
return FMT.format(d instanceof Date ? d : new Date(d))
}
function toUTC(s) {
const [y, m, d] = String(s).split('-').map(Number)
return new Date(Date.UTC(y, (m || 1) - 1, d || 1))
}
function fromUTC(dt) {
return dt.toISOString().slice(0, 10)
}
export function addDays(s, n) {
const d = toUTC(s)
d.setUTCDate(d.getUTCDate() + n)
return fromUTC(d)
}
export function addMonths(s, n) {
const [y, m] = String(s).split('-').map(Number)
const first = new Date(Date.UTC(y, (m || 1) - 1 + n, 1))
const day = Number(String(s).slice(8, 10)) || 1
const lastDay = new Date(Date.UTC(first.getUTCFullYear(), first.getUTCMonth() + 1, 0)).getUTCDate()
first.setUTCDate(Math.min(day, lastDay))
return fromUTC(first)
}
/** 周一为一周第一天 */
export function startOfWeek(s) {
const dow = (toUTC(s).getUTCDay() + 6) % 7
return addDays(s, -dow)
}
export const PERIOD_LABEL = { day: '每天', week: '每周', month: '每月', year: '每年' }
export function periodRange(period, s) {
switch (period) {
case 'week': {
const start = startOfWeek(s)
return { start, end: addDays(start, 6) }
}
case 'month': {
const start = String(s).slice(0, 8) + '01'
const [y, m] = String(s).split('-').map(Number)
return { start, end: fromUTC(new Date(Date.UTC(y, m || 1, 0))) }
}
case 'year': {
const y = String(s).slice(0, 4)
return { start: y + '-01-01', end: y + '-12-31' }
}
default: return { start: s, end: s }
}
}
/** 月历网格: 返回 6*7 = 42 天 (含前后月的补位), 周一起始 */
export function monthGrid(monthStr) {
const first = monthStr + '-01'
const gridStart = startOfWeek(first)
const days = []
for (let i = 0; i < 42; i++) days.push(addDays(gridStart, i))
return days
}
export function weekdayLabels() {
return ['一', '二', '三', '四', '五', '六', '日']
}
/** 相对今天的文案: 昨天 / 今天 / 明天 / MM-DD */
export function dayLabel(dateStr, today = toDateStr()) {
if (dateStr === today) return '今天'
if (dateStr === addDays(today, -1)) return '昨天'
if (dateStr === addDays(today, 1)) return '明天'
return dateStr.slice(5).replace('-', '/')
}
export function fmtMonth(monthStr) {
const [y, m] = monthStr.split('-')
return y + '年' + Number(m) + '月'
}
+168
View File
@@ -500,3 +500,171 @@ input, textarea, select { font: inherit; }
color: #fff;
}
.oil-day.future .oil-day-status { color: rgba(255,255,255,.85); }
/* ================== 打卡 / 日历 / 清单 (V0.2) ================== */
.checkin-content { padding: 0 0 16px; flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; }
.checkin-content .adm-tabs-content { --content-padding: 0; }
/* ----- 环形进度 ----- */
.ring-wrap { position: relative; display: inline-flex; flex-shrink: 0; align-items: center; justify-content: center; }
.ring-wrap.clickable { cursor: pointer; }
.ring-wrap.disabled { opacity: .5; }
.ring-svg { display: block; }
.ring-bar { transition: stroke-dashoffset 0.45s cubic-bezier(0.4, 0, 0.2, 1); }
.ring-center {
position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
font-size: 13px; font-weight: 600; color: var(--color-text); font-variant-numeric: tabular-nums;
}
.ring-num em { font-style: normal; font-size: 10px; color: var(--color-muted); font-weight: 400; }
.ring-check { animation: ring-check-in 0.32s ease; }
@keyframes ring-check-in { from { transform: scale(0.4); opacity: 0; } to { transform: scale(1); opacity: 1; } }
.ring-over {
position: absolute; right: -2px; bottom: -2px; font-size: 10px; font-weight: 600;
color: #fff; background: #ff8f1f; border-radius: 8px; padding: 0 4px; line-height: 14px;
}
/* ----- 完成特效 ----- */
.celebrate { position: absolute; inset: 0; pointer-events: none; z-index: 6; }
.celebrate i {
position: absolute; left: 50%; top: 50%; width: 7px; height: 7px; margin: -3.5px 0 0 -3.5px;
border-radius: 50%; animation: burst-fly 0.7s ease-out forwards;
}
@keyframes burst-fly {
0% { transform: rotate(var(--a)) translateX(0) scale(1); opacity: 1; }
70% { opacity: 1; }
100% { transform: rotate(var(--a)) translateX(var(--d)) scale(0.3); opacity: 0; }
}
.celebrate-ring {
position: absolute; left: 50%; top: 50%; width: 64px; height: 64px; margin: -32px 0 0 -32px;
border-radius: 50%; border: 2px solid var(--color-primary); animation: ring-pulse 0.7s ease-out forwards;
}
@keyframes ring-pulse {
0% { transform: scale(0.3); opacity: 0.9; }
100% { transform: scale(1.7); opacity: 0; }
}
/* ----- 打卡视图 ----- */
.habit-view { padding: 0 12px 20px; }
.habit-head { display: flex; justify-content: space-between; align-items: flex-end; gap: 8px; padding: 10px 2px; }
.habit-date { font-size: 18px; font-weight: 700; color: var(--color-text); }
.habit-summary { font-size: 12px; color: var(--color-muted); margin-top: 2px; }
.habit-summary strong { color: var(--color-primary); font-size: 14px; }
.habit-badge { margin-left: 6px; font-size: 11px; color: #ad6800; background: #fff7e6; border-radius: 4px; padding: 1px 5px; }
.habit-head-right { display: flex; gap: 6px; flex-shrink: 0; }
.checkin-empty { padding: 48px 0; text-align: center; display: flex; flex-direction: column; align-items: center; gap: 12px; }
.habit-list { display: flex; flex-direction: column; gap: 10px; }
.habit-card {
position: relative; background: #fff; border-radius: 10px; padding: 12px;
display: grid; grid-template-columns: auto 1fr auto; grid-template-areas: "ring main actions" "foot foot foot";
gap: 8px 12px; box-shadow: 0 1px 2px rgba(0,0,0,.04);
transition: background 0.2s ease;
}
.habit-card:active { background: #fafafa; }
.habit-card.done { background: #f7fbf8; }
.habit-card .ring-wrap { grid-area: ring; }
.habit-main { grid-area: main; min-width: 0; }
.habit-actions { grid-area: actions; display: flex; align-items: center; }
.habit-foot {
grid-area: foot; display: flex; gap: 14px; padding-top: 8px; border-top: 1px dashed var(--color-border);
}
.habit-name { font-size: 15px; font-weight: 600; color: var(--color-text); word-break: break-all; }
.habit-meta { font-size: 12px; color: var(--color-muted); margin-top: 3px; }
.habit-streak { margin-left: 8px; color: #ff8f1f; background: rgba(255,143,31,.12); border-radius: 4px; padding: 1px 6px; }
.habit-note { font-size: 11px; color: var(--color-muted); margin-top: 3px; }
.habit-range { font-size: 11px; color: var(--color-muted); margin-top: 3px; font-variant-numeric: tabular-nums; }
.habit-over { margin-left: 8px; color: #ff8f1f; }
.habit-link { font-size: 12px; color: var(--color-primary); cursor: pointer; }
.habit-link.danger { color: var(--color-danger); }
/* ----- 日历视图 ----- */
.calendar-view { padding: 0 12px 20px; }
.cal-month-bar { display: flex; align-items: center; gap: 10px; padding: 8px 2px; }
.cal-month { font-size: 16px; font-weight: 700; color: var(--color-text); }
.cal-arrow { font-size: 20px; color: var(--color-muted); cursor: pointer; padding: 0 4px; line-height: 1; }
.cal-today-btn { margin-left: auto; font-size: 12px; color: var(--color-primary); cursor: pointer; }
.cal-quick { padding: 2px 0 10px; }
.cal-grid { background: #fff; border-radius: 10px; padding: 8px 6px 10px; box-shadow: 0 1px 2px rgba(0,0,0,.04); }
.cal-weekday, .cal-days { display: grid; grid-template-columns: repeat(7, 1fr); }
.cal-weekday span { text-align: center; font-size: 11px; color: var(--color-muted); padding: 4px 0; }
.cal-day {
position: relative; height: 38px; display: flex; flex-direction: column; align-items: center;
justify-content: center; border-radius: 8px; cursor: pointer;
}
.cal-day-num { font-size: 13px; color: var(--color-text); font-variant-numeric: tabular-nums; }
.cal-day.out .cal-day-num { color: #c8c8c8; }
.cal-day.future .cal-day-num { color: #b0b0b0; }
.cal-day.today .cal-day-num { color: var(--color-primary); font-weight: 700; }
.cal-day.selected { background: var(--color-primary); }
.cal-day.selected .cal-day-num { color: #fff; font-weight: 600; }
.cal-dot { width: 5px; height: 5px; border-radius: 50%; margin-top: 3px; }
.cal-dot.partial { background: #ffc53d; }
.cal-dot.full { background: #00b578; }
.cal-dot.over { background: #a855f7; }
.cal-day.selected .cal-dot { background: #fff; }
.cal-rate { font-size: 12px; color: var(--color-muted); padding: 8px 2px; }
.cal-rate strong { color: var(--color-primary); }
.cal-detail { margin-top: 6px; }
.cal-detail-title { font-size: 14px; font-weight: 600; color: var(--color-text); padding: 6px 2px 8px; }
.cal-item {
position: relative; display: flex; align-items: center; gap: 8px; background: #fff;
border-radius: 8px; padding: 10px 12px; margin-bottom: 8px; box-shadow: 0 1px 2px rgba(0,0,0,.04);
}
.cal-item.done { background: #f7fbf8; }
.cal-item-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
.cal-item-name { font-size: 14px; font-weight: 600; color: var(--color-text); }
.cal-item-meta { font-size: 11px; color: var(--color-muted); flex: 1; text-align: right; font-variant-numeric: tabular-nums; }
.cal-item-right { flex-shrink: 0; }
.cal-item-plan { font-size: 12px; color: var(--color-muted); }
/* ----- 清单视图 ----- */
.list-view { padding: 0 12px 20px; }
.list-view-head { display: flex; align-items: center; gap: 8px; padding: 8px 0 10px; }
.list-view-head .adm-segmented { flex: 1; }
.checklist-wrap { display: flex; flex-direction: column; gap: 10px; }
.checklist-card {
position: relative; background: #fff; border-radius: 10px; padding: 12px;
box-shadow: 0 1px 2px rgba(0,0,0,.04);
}
.checklist-card.done { background: #f7fbf8; }
.checklist-head { cursor: pointer; }
.checklist-title-row { display: flex; align-items: center; gap: 8px; }
.checklist-title { font-size: 15px; font-weight: 600; color: var(--color-text); }
.checklist-scene { font-size: 11px; color: var(--color-primary); background: #f0f5ff; border-radius: 4px; padding: 1px 6px; }
.checklist-progress-row { display: flex; align-items: center; gap: 8px; margin-top: 8px; }
.checklist-bar { flex: 1; height: 6px; border-radius: 3px; background: #f0f0f0; overflow: hidden; }
.checklist-bar-fill { height: 100%; border-radius: 3px; background: #00b578; transition: width 0.4s ease; }
.checklist-count { font-size: 12px; color: var(--color-muted); font-variant-numeric: tabular-nums; }
.checklist-arrow { color: var(--color-muted); transition: transform 0.2s ease; }
.checklist-arrow.open { transform: rotate(90deg); }
.checklist-body { margin-top: 10px; padding-top: 8px; border-top: 1px dashed var(--color-border); }
.checklist-item { display: flex; align-items: center; gap: 8px; padding: 7px 0; }
.check-box {
width: 20px; height: 20px; border-radius: 50%; border: 1.5px solid #d0d0d0; flex-shrink: 0;
display: inline-flex; align-items: center; justify-content: center; cursor: pointer;
transition: background 0.2s ease, border-color 0.2s ease;
}
.check-box svg { opacity: 0; transition: opacity 0.15s ease; }
.checklist-item.done .check-box { background: #00b578; border-color: #00b578; }
.checklist-item.done .check-box svg { opacity: 1; }
.checklist-item-title { flex: 1; font-size: 14px; color: var(--color-text); cursor: pointer; }
.checklist-item-title em { display: block; font-style: normal; font-size: 11px; color: var(--color-muted); margin-top: 2px; }
.checklist-item.done .checklist-item-title { color: #a0a0a0; text-decoration: line-through; }
.checklist-item-del { color: #ccc; font-size: 18px; line-height: 1; padding: 0 4px; cursor: pointer; }
.checklist-add { display: flex; align-items: center; gap: 8px; margin-top: 8px; }
.checklist-add .adm-input { flex: 1; border: 1px solid var(--color-border); border-radius: 6px; padding: 6px 8px; }
.checklist-actions { display: flex; flex-wrap: wrap; gap: 12px; margin-top: 10px; }
/* ----- 表单弹窗 (打卡项 / 清单) ----- */
.form-popup { padding: 16px 16px 8px; }
.form-title { font-size: 16px; font-weight: 700; color: var(--color-text); margin-bottom: 12px; }
.form-field { margin-bottom: 14px; }
.form-field > label { display: block; font-size: 12px; color: var(--color-muted); margin-bottom: 6px; }
.form-field .adm-input { border: 1px solid var(--color-border); border-radius: 8px; padding: 8px 10px; }
.form-actions { margin-top: 18px; }
.color-row { display: flex; gap: 10px; flex-wrap: wrap; }
.color-dot {
width: 26px; height: 26px; border-radius: 50%; cursor: pointer; display: inline-block;
border: 2px solid transparent; transition: transform 0.15s ease;
}
.color-dot.active { border-color: rgba(0,0,0,.25); transform: scale(1.12); }