Compare commits
3
Commits
54a3224f00
...
e1e0880ce6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e1e0880ce6 | ||
|
|
9b4705870d | ||
|
|
004f8c262d |
@@ -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
|
||||
|
||||
@@ -45,3 +45,5 @@ cache/apizero/
|
||||
.cache/
|
||||
# 米家凭证缓存 (含 token, 绝不能提交)
|
||||
server/mihome/.cache/
|
||||
# 工作区记忆/会话数据 (本地私有, 不入库)
|
||||
.workbuddy/
|
||||
|
||||
@@ -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 数据结构。
|
||||
@@ -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 < done < 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 > 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>全部达标且存在任一打卡项当日次数 > 期望</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 > 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>
|
||||
@@ -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. **数据导出**:清单完成后导出为图片或文本(便于发群里确认)。
|
||||
+47
-8
@@ -1,6 +1,6 @@
|
||||
# iBoard V0.2 需求文档 — 打卡 / 日历 / 清单
|
||||
|
||||
> 版本:V0.2 · 分支:`dev-0.2` · 状态:待评审 · 日期:2026-10-22
|
||||
> 版本:V0.2 · 分支:`dev-0.2` · 日期:2026-10-22
|
||||
> 目标项目:iboard(Tauri 2 + React 18 + antd-mobile + Express + Prisma/MariaDB,移动端优先)
|
||||
> 基线版本:V0.1(`dev-0.1`)
|
||||
|
||||
@@ -8,16 +8,16 @@
|
||||
|
||||
## 1. 总体描述
|
||||
|
||||
在 iBoard 移动端新增一个「打卡」模块,用**一个页面、三个视图**解决两类日常需求:
|
||||
|
||||
在 iBoard 移动端新增一个「打卡」模块一个「清单」模块,满足两类日常需求:
|
||||
### 打卡
|
||||
1. **周期性打卡**(习惯养成):用户定义"每天 n 次 / 每周 n 次 / 每月 n 次 / 每年 n 次"的目标,
|
||||
每次打卡后卡片上的**环形进度**按 `已完成次数 / 目标次数` 增长,达标时环形闭合并播放完成特效;
|
||||
2. **日历回看**:以日历为载体,快速查看**今天**(要做什么)、**昨天**(做得怎样)、
|
||||
**明天**(计划做什么),日期上以圆点/实心标记完成情况;
|
||||
3. **检查清单**(场景化一次性事项):面向**出差、回老家**等临时场景,逐条勾选确认,
|
||||
避免遗漏;清单可复用为模板,下次一键生成。
|
||||
|
||||
一句话:**用环形进度做周期性打卡,用日历做时间维度的回看与计划,用清单覆盖临时性的检查场景。**
|
||||
### 清单
|
||||
1. **清单**(场景化一次性事项):面向**出差、回老家**等临时场景,逐条勾选确认,
|
||||
避免遗漏;清单可复用为模板,下次一键生成。
|
||||
|
||||
### 1.1 本期范围
|
||||
|
||||
@@ -117,8 +117,8 @@
|
||||
|
||||
| 编号 | 需求描述 | 验收要点 | 优先级 |
|
||||
| --- | --- | --- | --- |
|
||||
| F-SY-01 | 底部 TabBar 新增**「打卡」页**(现有 4 个 Tab → 5 个),作为本模块唯一入口 | 图标风格与现有 TabIcon 一致 | P0 |
|
||||
| F-SY-02 | 「打卡」页内以子 Tab(Segmented / SwipeTabs)切换 **打卡 / 日历 / 清单** 三个视图 | 复用现有 `SwipeTabs` 组件 | P0 |
|
||||
| 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 |
|
||||
@@ -293,3 +293,42 @@ model CheckListTemplate {
|
||||
```
|
||||
|
||||
> 说明:周期进度由 `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` | 删除自定义模板 |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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]
|
||||
|
||||
|
||||
@@ -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
@@ -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;
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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` 验证 → 提交迁移目录。
|
||||
|
||||
- 首页主要是放经济文章
|
||||
- 数据主要是放各种经济数据和图表。
|
||||
- 策略是放按照经济理论组织的相关数据和图表的对比。
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 />} />
|
||||
|
||||
@@ -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' }),
|
||||
}
|
||||
@@ -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: '我' },
|
||||
]
|
||||
|
||||
|
||||
@@ -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 */ }
|
||||
}
|
||||
@@ -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} />
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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) + '月'
|
||||
}
|
||||
@@ -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); }
|
||||
|
||||
Reference in New Issue
Block a user