Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5f41cb05b7 | ||
|
|
4a04720a5e | ||
|
|
e1e0880ce6 | ||
|
|
9b4705870d | ||
|
|
004f8c262d | ||
|
|
54a3224f00 |
@@ -30,6 +30,11 @@ jobs:
|
||||
node-version: 22
|
||||
cache: 'npm'
|
||||
|
||||
# 版本号一致性: package.json / tauri.conf.json / Cargo.toml / Cargo.lock 必须完全相等。
|
||||
# tauri.conf.json 的 version 就是 APK 的 versionName, 漏改会打出一个版本号错误的安装包。
|
||||
- name: Check version consistency
|
||||
run: node scripts/ci/check-version.mjs
|
||||
|
||||
- name: Setup JDK 17
|
||||
uses: https://gitea.com/actions/setup-java@v4
|
||||
with:
|
||||
|
||||
@@ -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:
|
||||
@@ -17,6 +21,11 @@ jobs:
|
||||
# 其他 VITE_API_BASE 值。否则 OTA 包里的前端可能连错后端, 导致
|
||||
# "OTA 更新后登录失败/进不去内部"。与 android-build.yml 保持一致。
|
||||
VITE_API_BASE: http://bh.vps.honor3.com:3001
|
||||
# OTA bundle 要求的最低原生二进制版本。
|
||||
# 与 package.json 的应用版本是**两个概念**: 前端 OTA 不碰原生层,
|
||||
# 现有 APK 仍是 0.1.0, 所以这里固定 0.1.0 (否则旧 APK 会被判定为不兼容而收不到更新)。
|
||||
# 重打 APK 并确认原生层无破坏后, 再提升此值。
|
||||
OTA_MIN_BINARY: '0.1.0'
|
||||
steps:
|
||||
- uses: https://gitea.com/actions/checkout@v4
|
||||
|
||||
@@ -25,6 +34,15 @@ jobs:
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Check version consistency & branch
|
||||
# 1) package.json / tauri.conf.json / Cargo.toml / Cargo.lock 四处版本必须完全相等且不带 -ota
|
||||
# (tauri.conf.json 的 version 就是 APK 的 versionName)
|
||||
# 2) 防止 OTA 版本号与分支错位: OTA 包的 version 直接取自 package.json,
|
||||
# dev-0.2 分支上必须是 0.2.x, 否则线上 OTA 会报成上一个版本的号
|
||||
env:
|
||||
GITHUB_REF_NAME: ${{ github.ref_name }}
|
||||
run: node scripts/ci/check-version.mjs
|
||||
|
||||
- name: Install deps
|
||||
run: npm ci --no-audit --no-fund
|
||||
|
||||
@@ -43,7 +61,7 @@ jobs:
|
||||
OTA_NOTES: ${{ github.sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
node scripts/build-ota.mjs --out ota-package --notes ${OTA_NOTES}
|
||||
node scripts/build-ota.mjs --out ota-package --notes ${OTA_NOTES} --min-binary ${OTA_MIN_BINARY}
|
||||
echo "===== latest.json ====="
|
||||
cat ota-package/latest.json
|
||||
echo "===== bundle sha256 ====="
|
||||
@@ -57,10 +75,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
|
||||
|
||||
@@ -21,10 +21,10 @@ on:
|
||||
type: string
|
||||
default: ''
|
||||
min_binary:
|
||||
description: '最低 binary 版本 (留空用 src-tauri/tauri.conf.json 的 version)'
|
||||
description: '最低 binary 版本 (默认 0.1.0 = 现有 APK 的原生版本, 与前端应用版本无关)'
|
||||
required: false
|
||||
type: string
|
||||
default: ''
|
||||
default: '0.1.0'
|
||||
|
||||
jobs:
|
||||
build-ota:
|
||||
@@ -56,7 +56,7 @@ jobs:
|
||||
set -euo pipefail
|
||||
OUT="ota-package"
|
||||
ARGS="--out ${OUT}"
|
||||
# inputs 留空时 → 不传 --sequence/--min-binary, 脚本默认 sequence=时间戳, min-binary=tauri.conf version
|
||||
# sequence 留空 → 脚本默认用时间戳; min_binary 默认 0.1.0 (与前端版本解耦)
|
||||
if [ -n "${{ inputs.sequence }}" ]; then ARGS="${ARGS} --sequence ${{ inputs.sequence }}"; fi
|
||||
if [ -n "${OTA_NOTES}" ]; then ARGS="${ARGS} --notes ${OTA_NOTES}"; fi
|
||||
if [ -n "${{ inputs.min_binary }}" ]; then ARGS="${ARGS} --min-binary ${{ inputs.min_binary }}"; fi
|
||||
|
||||
+47
-45
@@ -1,47 +1,49 @@
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# production
|
||||
/build
|
||||
/out
|
||||
/.next
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
# Editor directories and files
|
||||
.idea
|
||||
.vscode
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
dist/*
|
||||
|
||||
# local env (contains API keys)
|
||||
/.env
|
||||
.reasonix/*
|
||||
# OTA 升级: 产物 (运行时生成, 部署时单独管理) + 私钥 (绝不能提交)
|
||||
ota-package/*
|
||||
!ota-package/README.md
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
|
||||
# apizero 文件缓存 (运行时按需生成)
|
||||
cache/apizero/
|
||||
# Dockerfile 持久卷挂载点 (跟 ota-package 同类)
|
||||
.cache/
|
||||
# 米家凭证缓存 (含 token, 绝不能提交)
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# production
|
||||
/build
|
||||
/out
|
||||
/.next
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
# Editor directories and files
|
||||
.idea
|
||||
.vscode
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
dist/*
|
||||
|
||||
# local env (contains API keys)
|
||||
/.env
|
||||
.reasonix/*
|
||||
# OTA 升级: 产物 (运行时生成, 部署时单独管理) + 私钥 (绝不能提交)
|
||||
ota-package/*
|
||||
!ota-package/README.md
|
||||
|
||||
# apizero 文件缓存 (运行时按需生成)
|
||||
cache/apizero/
|
||||
# Dockerfile 持久卷挂载点 (跟 ota-package 同类)
|
||||
.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 数据结构。
|
||||
+1082
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,444 @@
|
||||
# iBoard V0.2 设计文档 — 打卡 / 日历 / 清单
|
||||
|
||||
| 项 | 内容 |
|
||||
| --- | --- |
|
||||
| 版本 | V0.2(对应分支 `dev-0.2`,应用版本 `0.2.0`) |
|
||||
| 文档状态 | 已实现(与代码一致;含 2026-10-22 设计变更,见 §10) |
|
||||
| 上游文档 | `需求文档.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 之上**新增「打卡」模块并升为默认首页**(底部 Tab 由 5 个精简为 4 个:打卡 / 宏观 / 监控 / 我),页面内以三个子视图承载三类需求:
|
||||
|
||||
| 子视图 | 解决问题 | 核心隐喻 |
|
||||
| --- | --- | --- |
|
||||
| **打卡** | 周期性习惯的量化坚持 | 环形进度,点环即打卡 |
|
||||
| **日历** | 回看昨天、执行今天、预知明天 | 月历网格 + 日状态点 |
|
||||
| **清单** | 出差/回老家等一次性场景的漏项检查 | 勾选清单 + 进度条 |
|
||||
|
||||
> **Tab 结构(V0.2 终态)**
|
||||
>
|
||||
> | 顺序 | Tab | 路由 | 说明 |
|
||||
> | --- | --- | --- | --- |
|
||||
> | 1 | **打卡(默认)** | `/checkin` | 根路径 `/` 重定向到此;`/checkin` 内含 打卡/日历/清单 三视图 |
|
||||
> | 2 | 宏观 | `/macro/*` | 油价 / 经济数据 / 策略研究 / 经济文章 / **报警** / **新闻**(后两者由原「首页」并入) |
|
||||
> | 3 | 监控 | `/monitor` | 服务器 + 米家环境 |
|
||||
> | 4 | 我 | `/me` | 用户信息 + 前端 OTA 升级 |
|
||||
>
|
||||
> 原「首页」Tab 已移除:其中的**报警**、**新闻** 两个模块并入宏观,作为其第 5、6 个子 Tab(`/macro/alerts`、`/macro/news`),新闻详情改为 `/macro/news/:id` 以保持宏观 Tab 高亮。
|
||||
|
||||
**不纳入 V0.2**:多用户共享清单、提醒推送、数据导出、打卡项分组、年视图热力图(见 §9 演进)。
|
||||
|
||||
### 1.2 架构总览
|
||||
|
||||
```
|
||||
┌──────────────────────── 前端 (React + antd-mobile, 打包进 Tauri / OTA) ────────────────────────┐
|
||||
│ App.jsx 路由 (默认 / → /checkin) │
|
||||
│ └ /checkin ──► CheckinPage ── SwipeTabs ──┬── HabitView (打卡) │
|
||||
│ ├── CalendarView (日历) │
|
||||
│ └── ListView (清单) │
|
||||
│ └ /macro/* ──► MacroPage ── 油价/经济数据/策略研究/经济文章/报警/新闻 │
|
||||
│ 组件: 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
|
||||
api/ota.js # OTA 封装 + 判新模式(version/sequence)
|
||||
utils/version.js # 版本号字段比较(纯函数, 可单测)
|
||||
utils/format.js # 日期格式化
|
||||
components/RingProgress.jsx # 环形进度 + Celebrate 特效
|
||||
components/BottomNav.jsx # Tab: 打卡(默认)/宏观/监控/我
|
||||
components/TabIcon.jsx # 图标集(移除 home)
|
||||
pages/checkin/CheckinPage.jsx # 容器:三视图切换
|
||||
pages/checkin/HabitView.jsx # 打卡视图
|
||||
pages/checkin/CalendarView.jsx # 日历视图
|
||||
pages/checkin/ListView.jsx # 清单视图
|
||||
pages/checkin/date.js # 前端日期工具
|
||||
pages/MacroPage.jsx # 宏观: 6 个子 Tab(含报警/新闻)
|
||||
pages/macro/AlertsView.jsx # 报警(原首页)
|
||||
pages/macro/NewsView.jsx # 新闻(原首页)
|
||||
pages/MePage.jsx # 我: OTA 组内「开发版本」开关
|
||||
App.jsx # / → /checkin; /macro/*; 移除 /news/:id
|
||||
styles/globals.css # 打卡相关样式段
|
||||
scripts/ci/check-version.mjs # CI: 校验分支名 ↔ package.json 版本
|
||||
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 路由与容器
|
||||
|
||||
```
|
||||
底部 Tab (BottomNav) 打卡(默认) ─ 宏观 ─ 监控 ─ 我
|
||||
/ → <Navigate to="/checkin" replace />
|
||||
/checkin → CheckinPage
|
||||
├─ NavBar「打卡」
|
||||
└─ SwipeTabs(左右滑动切换,与既有模块交互一致)
|
||||
├─ habit → HabitView (受 date 影响)
|
||||
├─ calendar → CalendarView (持有选中 date,可回写容器)
|
||||
└─ list → ListView (独立)
|
||||
/macro/* → MacroPage (油价 / 经济数据 / 策略研究 / 经济文章 / 报警 / 新闻)
|
||||
```
|
||||
|
||||
`activeKeyFromPath()` 对未匹配路径兜底返回**第一个 Tab(`/checkin`)**,与默认路由保持一致。
|
||||
|
||||
`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 // 新建/编辑弹窗
|
||||
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` 态半透明;**完成特效恒开**(V0.2 设计变更:取消页面内特效开关,无任何关闭入口) |
|
||||
|
||||
**完成特效 `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` 关闭(用户明确要求「特效永远打开」)。
|
||||
|
||||
### 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 跳过未结束周期) | 全通过 |
|
||||
| 单元 | 版本比较 10 条断言(`-ota` 后缀、`v` 前缀、缺位补 0、`0.10 > 0.9`、`beta.3 < beta.10`、空串) | 全通过 |
|
||||
| 接口冒烟 | 自签 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 可加汇总表 |
|
||||
| 未来日期禁止打卡 | 防止「提前打完一周」刷进度,符合「日历看明天=计划」语义 | 明天视图只读展示「计划」,明确文案 |
|
||||
| 特效恒开可能打扰 | 用户明确要求「特效永远打开」,不再提供开关 | 单次特效 ≤900ms 且 `pointer-events:none`,不影响连续打卡;若后续需要再评估恢复开关 |
|
||||
| Tab 调整为 4 个(打卡置左并默认) | 首页被取消,报警/新闻并入宏观后宏观有 6 个子 Tab | 子 Tab 可横向滚动;报警角标保留在子 Tab 上;底部 4 项在 375px 下热区充足(≥44px) |
|
||||
|
||||
---
|
||||
|
||||
## 9. 后续演进(V0.3+)
|
||||
|
||||
1. **提醒与通知**:`Habit.remindAt` + Tauri 本地通知,解决「忘打卡」。
|
||||
2. **补打放宽 / 历史编辑**:当前 7 天限制可配置化。
|
||||
3. **统计视图**:连续天数榜单、月/年热力图(届时引入 `HabitDailyStat` 汇总表)。
|
||||
4. **打卡项分组与排序拖拽**:现有 `sortOrder` 已预留,仅需交互。
|
||||
5. **清单协作/分享**:引入 `CheckListShare`。
|
||||
6. **数据导出**:清单完成后导出为图片或文本(便于发群里确认)。
|
||||
|
||||
---
|
||||
|
||||
## 10. 设计变更记录(2026-10-22)
|
||||
|
||||
### 10.1 变更清单
|
||||
|
||||
| # | 类型 | 变更 | 影响文件 |
|
||||
| --- | --- | --- | --- |
|
||||
| 1 | Bug | OTA 包版本与分支不对应:`dev-0.2` 上仍是 `0.1.0` → OTA 报成 `0.1.0-ota`。统一升到 `0.2.0`,OTA 包变为 **`0.2.0-ota`**,并加 CI 强校验 | `package.json`、`src-tauri/tauri.conf.json`、`src-tauri/Cargo.toml`、`Cargo.lock`、`scripts/ci/check-version.mjs`、`docker-build.yml` |
|
||||
| 2 | 功能 | 我的页 OTA 组新增**「开发版本」Switch**(默认关),切换判新口径:开=`build sequence`,关=版本号字段比较;开关值持久化在设备端 | `src/api/ota.js`、`src/utils/version.js`、`src/pages/MePage.jsx` |
|
||||
| 3 | 设计 | 打卡 Tab 移到**最左边并默认选中**;打卡页移除**特效开关**,特效永远打开 | `BottomNav.jsx`、`App.jsx`、`TabIcon.jsx`、`HabitView.jsx`、`RingProgress.jsx`、`globals.css` |
|
||||
| 4 | 设计 | 取消「首页」Tab,其**报警 / 新闻**并入宏观,成为宏观的第 5、6 个子 Tab | `MacroPage.jsx`、新增 `macro/AlertsView.jsx`、`macro/NewsView.jsx`、删除 `HomePage.jsx` |
|
||||
|
||||
### 10.2 版本号为什么必须跟分支走
|
||||
|
||||
`scripts/build-ota.mjs` 里 `manifest.version = package.json.version + '-ota'`,也就是**版本号的唯一来源仍是 `package.json`**。
|
||||
分支按版本命名(`dev-0.2`),但版本号是手工改的,一旦忘改就会出现「分支 0.2、包版本 0.1」的错位,客户端判新逻辑全部失准。
|
||||
|
||||
**同一版本号的两种产物写法**(2026-10-22 补充确认):
|
||||
|
||||
```
|
||||
分支 dev-0.2 → 0.2.0
|
||||
├─ APK / 桌面安装包 0.2.0 裸版本号,无后缀
|
||||
├─ 前端运行时 0.2.0 裸版本号,无后缀
|
||||
└─ OTA 热更包 0.2.0-ota 多一个 -ota 标记
|
||||
```
|
||||
|
||||
即 APK 与 OTA 的版本主体完全一致,唯一差别是 OTA 多 `-ota` 后缀;`package.json` 只写裸版本号,后缀由打包脚本追加。
|
||||
|
||||
| 环节 | 取值 | 现状 |
|
||||
| --- | --- | --- |
|
||||
| `package.json` `version` | `0.2.0` | 唯一手动管理点,**不得带 `-ota`** |
|
||||
| `src-tauri/tauri.conf.json` `version` | `0.2.0` | 决定 **APK `versionName`**(经 `tauri android init` 写入 `tauri.properties`)与桌面包版本 |
|
||||
| `src-tauri/Cargo.toml` / `Cargo.lock` | `0.2.0` | Rust 包版本,与上两者保持一致 |
|
||||
| OTA `latest.json.version` | `0.2.0-ota` | 打包脚本自动拼 `-ota` 后缀 |
|
||||
| `src/version.js` `APP_VERSION` | `0.2.0` | 前端展示与版本比较的基准 |
|
||||
| APK `versionCode` | `2000` | `major*1000000 + minor*1000 + patch`,必须单调递增才能覆盖安装(`0.1.0` = 1000) |
|
||||
| OTA `latest.json.min_binary_version` | `0.1.0` | **与前端版本解耦**:前端 bundle 要求的最低原生二进制版本。现有 APK 原生层仍是 0.1.0,跟着抬会导致旧 APK 收不到更新,CI 固定传 `--min-binary 0.1.0` |
|
||||
| CI 守卫 | `scripts/ci/check-version.mjs` | ① 四处版本必须完全相等且不含 `-ota`(`main`/`master` 也查);② 从 `GITHUB_REF_NAME` 解析 `dev-<major>.<minor>`,要求 `version` 以该前缀开头;任一不满足**构建直接失败**。`docker-build.yml` 与 `android-build.yml` 都在构建前执行 |
|
||||
|
||||
### 10.3 判新口径:版本号 vs build sequence
|
||||
|
||||
「我的 → 前端升级 (OTA)」组内新增 `开发版本` 开关(antd-mobile `Switch`),默认**关闭**:
|
||||
|
||||
| 开关 | 判新依据 | 实现路径 | 适用场景 |
|
||||
| --- | --- | --- | --- |
|
||||
| **关(默认)** | **版本号字段大小** | `GET /api/ota/check/0` 取 `manifest.version` → `compareVersion(manifest.version, 当前运行版本) > 0` 才算新 | 生产口径。旧分支被重新构建(sequence 变大但版本更小)时不会把设备拉回旧版 |
|
||||
| **开** | **build sequence** | `tauri-plugin-hotswap` 的 `checkUpdate()`(内部拿本地 sequence 请求 `/api/ota/check/:seq`,服务端只回比它大的) | 开发联调。每次构建 sequence 都变,保证任何一次提交都能推到设备上 |
|
||||
|
||||
补充约定:
|
||||
|
||||
- **持久化**:`localStorage['iboard_ota_dev_mode'] = '1' | '0'`(默认 `'0'`)。值只存在设备端 WebView 存储里,不进数据库、不上报,换设备/清数据即回默认。
|
||||
- **当前运行版本**:已应用过 OTA 覆盖包时取覆盖包 `version`(插件 `getVersionInfo()`),否则取内置包 `APP_VERSION`;避免「内置包 0.1.0 + 覆盖包 0.2.0」时永远判定有新版本。
|
||||
- **切换开关会重置检查结果**(`otaState='idle'`、清空 `otaInfo`),避免上一种口径的结论误导用户。
|
||||
- **Web 端**:浏览器里拿不到本地 sequence。默认口径按版本号比较;开发口径退化为「服务端版本 ≠ 本地版本即算新」(近似 sequence 语义)。Web 端只提示刷新,不下载不替换,与原设计一致。
|
||||
|
||||
**版本比较算法**(`src/utils/version.js`,纯函数可单测):
|
||||
|
||||
```js
|
||||
parseVersionFields('0.2.0-ota') // → [0, 2, 0]
|
||||
compareVersion('0.2.0-ota','0.1.0') // → 1 (0.2.0 更新)
|
||||
compareVersion('0.2','0.2.0') // → 0 (缺位补 0)
|
||||
```
|
||||
|
||||
规则:按非数字字符切段 → 过滤空段 → 转数字 → 逐段比较,位数不足补 0。单测 10 条覆盖 `-ota` 后缀、`v` 前缀、多位段(`0.10 > 0.9`)、空串、`beta.3 < beta.10`,全部通过。
|
||||
|
||||
### 10.4 交互与信息架构变更
|
||||
|
||||
| 项 | 变更前 | 变更后 |
|
||||
| --- | --- | --- |
|
||||
| 底部 Tab | 首页 / 宏观 / 监控 / 打卡 / 我(5 个) | **打卡 / 宏观 / 监控 / 我(4 个)**,打卡在最左 |
|
||||
| 默认落地 | `/` → 首页(默认选中新闻) | `/` → **`/checkin`**;`RedirectIfAuthed` 与 404 兜底同样指向 `/checkin` |
|
||||
| 报警 / 新闻 | 首页内的 2 个 Tab | 宏观的第 5、6 个子 Tab(`/macro/alerts`、`/macro/news`),报警角标保留 |
|
||||
| 新闻详情 | `/news/:id`(底部无 Tab 高亮) | **`/macro/news/:id`**(宏观 Tab 保持高亮) |
|
||||
| 打卡页头部 | `[特效开/关]` + `[+ 新建]` | 只保留 `[+ 新建]`;特效恒开 |
|
||||
| 特效实现 | `effectsEnabled()` 读 `localStorage.iboard_celebrate` + `prefers-reduced-motion` 降级 | 删除开关与降级判断,`Celebrate` 只要 `fire` 变化就播放 |
|
||||
|
||||
### 10.5 界面设计同步
|
||||
|
||||
`界面设计.html` 已同步:底部 Tab 改为 4 项且「打卡」高亮在首位、打卡页去掉特效开关按钮、新增「我的 · OTA 开发版本开关」屏、宏观新增「报警 / 新闻」子 Tab 与合并后的 6 Tab 导航。
|
||||
@@ -0,0 +1,348 @@
|
||||
# iBoard V0.2 需求文档 — 打卡 / 日历 / 清单
|
||||
|
||||
> 版本:V0.2 · 分支:`dev-0.2` · 日期:2026-10-22
|
||||
> 目标项目:iboard(Tauri 2 + React 18 + antd-mobile + Express + Prisma/MariaDB,移动端优先)
|
||||
> 基线版本:V0.1(`dev-0.1`)
|
||||
|
||||
---
|
||||
|
||||
## 1. 总体描述
|
||||
|
||||
在 iBoard 移动端新增一个「打卡」模块一个「清单」模块,满足两类日常需求:
|
||||
### 打卡
|
||||
1. **周期性打卡**(习惯养成):用户定义"每天 n 次 / 每周 n 次 / 每月 n 次 / 每年 n 次"的目标,
|
||||
每次打卡后卡片上的**环形进度**按 `已完成次数 / 目标次数` 增长,达标时环形闭合并播放完成特效;
|
||||
2. **日历回看**:以日历为载体,快速查看**今天**(要做什么)、**昨天**(做得怎样)、
|
||||
**明天**(计划做什么),日期上以圆点/实心标记完成情况;
|
||||
|
||||
### 清单
|
||||
1. **清单**(场景化一次性事项):面向**出差、回老家**等临时场景,逐条勾选确认,
|
||||
避免遗漏;清单可复用为模板,下次一键生成。
|
||||
|
||||
### 1.1 本期范围
|
||||
|
||||
- 单用户使用,数据按登录用户隔离(复用现有 JWT 鉴权)。
|
||||
- 移动端优先(`MobileShell` + 底部 TabBar 体系),桌面浏览器兼容可用即可。
|
||||
- 数据落库 MariaDB,通过现有 Express `/api/*` 提供接口。
|
||||
|
||||
### 1.2 不在本期范围(明确排除)
|
||||
|
||||
- 多人协作 / 共享打卡 / 打卡排行榜。
|
||||
- 系统通知、推送提醒、定时提醒(V0.2 不引入通知能力)。
|
||||
- 复杂统计报表(周报/月报/趋势图),本期只提供完成率概览。
|
||||
- 桌面端专用布局、横屏适配。
|
||||
- 清单的周期性重复(如"每次出差都生成"),本期只做**临时清单 + 模板手动复用**。
|
||||
|
||||
---
|
||||
|
||||
## 2. 术语与概念定义
|
||||
|
||||
| 术语 | 定义 |
|
||||
| --- | --- |
|
||||
| **打卡项(Habit)** | 一个被跟踪的周期性目标,如"喝水 每天 8 次""健身 每周 3 次" |
|
||||
| **周期类型** | `day` / `week` / `month` / `year`,决定进度统计窗口 |
|
||||
| **目标次数 n** | 一个周期内需要完成的打卡次数,n ≥ 1 |
|
||||
| **周期窗口** | 日=自然日;周=自然周(周一 00:00 至周日 23:59);月=自然月;年=自然年。时区统一 Asia/Shanghai |
|
||||
| **打卡记录(CheckIn)** | 一次打卡动作,归属到某个**本地日历日**(可补打到过去日期) |
|
||||
| **环形进度** | 卡片上的圆环组件,`进度 = min(已完成次数 / n, 1)`;达标=闭合 |
|
||||
| **清单(CheckList)** | 一次性检查清单,如"出差 · 10月杭州",含若干检查项 |
|
||||
| **检查项(CheckItem)** | 清单中的单条待检查事项,勾选即完成 |
|
||||
| **模板(Template)** | 可被复用的清单结构(一组检查项标题),用于一键生成新清单 |
|
||||
| **日期状态** | 某日的打卡完成情况:全部完成 / 部分完成 / 未开始 / 超额完成 |
|
||||
|
||||
---
|
||||
|
||||
## 3. 需求点
|
||||
|
||||
**编号规则**:`类型-模块-序号`。类型:F=功能需求、U=交互与体验、D=数据、N=非功能、E=异常与边界。
|
||||
模块:CK=打卡、CL=日历、LS=清单、SY=通用。
|
||||
|
||||
**优先级**:P0 = 本期必须有;P1 = 本期应该有;P2 = 有则更好(可延后)。
|
||||
|
||||
### 3.1 功能需求 — 打卡(F-CK)
|
||||
|
||||
| 编号 | 需求描述 | 验收要点 | 优先级 |
|
||||
| --- | --- | --- | --- |
|
||||
| F-CK-01 | 支持新建打卡项:名称、周期类型、目标次数 n、颜色、图标、备注 | 表单校验:名称必填且 ≤ 20 字;n 为 ≥1 的整数 | P0 |
|
||||
| F-CK-02 | 周期类型支持**每天 n 次 / 每周 n 次 / 每月 n 次 / 每年 n 次**四种 | 四种类型均可创建并正确统计 | P0 |
|
||||
| F-CK-03 | 卡片以**环形进度**展示当前周期完成情况,环中心显示 `已完成/n` | 进度 = 已完成次数 / n,上限 100% | P0 |
|
||||
| F-CK-04 | **每天 1 次**的打卡项:打卡后环形**直接闭合**(100%),卡片进入"已完成"态 | 一次点击即闭合,不出现 50% 之类的中间态 | P0 |
|
||||
| F-CK-05 | **每周 n 次**(及月/年):每打卡一次环形闭合对应百分比(如 n=3,打 1 次=33.3%、2 次=66.7%、3 次=100% 闭合) | 进度随每次打卡实时刷新,带过渡动画 | P0 |
|
||||
| F-CK-06 | 打卡主操作:点击卡片上的打卡按钮即完成一次打卡 | 单击即生效,响应 < 300ms(乐观更新) | P0 |
|
||||
| F-CK-07 | 打卡**完成特效**:达标(进度达 100%)时播放特效——环形闭合动画 + 光晕/粒子扩散 + 轻量触感反馈 | 特效时长 ≤ 800ms,不阻塞后续操作 | P0 |
|
||||
| F-CK-08 | 打卡记录需记录**发生日期**(本地日历日)与时间戳 | 支持按日回看,跨零点归属正确 | P0 |
|
||||
| F-CK-09 | 支持**撤销**:可撤销本周期内最近一次打卡(主要是误触场景) | 撤销后进度回退,记录物理删除或标记撤销 | P1 |
|
||||
| F-CK-10 | 支持**补打卡**:对过去日期补记一次打卡,默认允许回溯 7 天 | 补打后对应日期的状态与进度同步更新 | P1 |
|
||||
| F-CK-11 | **不允许提前打卡**(未来日期不可打卡) | 明天的项只展示为"计划",按钮置灰 | P0 |
|
||||
| F-CK-12 | 展示**连续完成天数/周期数(streak)** | 如"已连续 5 天",断签归零 | P1 |
|
||||
| F-CK-13 | 打卡项可**编辑**(改名/改 n/改颜色/改周期)与**停用(归档)** | 归档后不出现在今日待办,历史保留 | P1 |
|
||||
| F-CK-14 | 修改目标次数 n 后,**当前周期**进度按新 n 重算展示,不改写历史记录 | 例如 n 由 3 改 5,已完成 3 次 → 显示 3/5(60%) | P1 |
|
||||
| F-CK-15 | **超额打卡**:周期内超过 n 仍可继续打卡,环形保持闭合,中心显示 `n+k`(如 3+1) | 不清零、不报错 | P2 |
|
||||
| F-CK-16 | 打卡项支持**排序**(拖动或置顶)与分组展示 | 今日列表按"未完成在前、已完成在后"默认排序 | P2 |
|
||||
| F-CK-17 | 打卡时可附带**备注**(可选,纯文本) | 列表详情中可查看 | P2 |
|
||||
|
||||
### 3.2 功能需求 — 日历(F-CL)
|
||||
|
||||
| 编号 | 需求描述 | 验收要点 | 优先级 |
|
||||
| --- | --- | --- | --- |
|
||||
| F-CL-01 | 提供**月历视图**:当月日历网格,可切换上/下月,点击日期查看当日详情 | 周一起始,周一~周日七列 | P0 |
|
||||
| F-CL-02 | **今天**视图(默认):列出今天应打卡的项及完成状态,可直接打卡 | 打开页面默认定位今天 | P0 |
|
||||
| F-CL-03 | **昨天**视图:展示昨天各打卡项的**已完成结果**(完成/未完成/超额),未完成项可进行补打 | 以回看为主,标记为"已完成的历史" | P0 |
|
||||
| F-CL-04 | **明天**视图:展示**明天计划要打卡的项**(按周期规则推算),只读,不可打卡 | 明确标注"计划",打卡按钮不可用 | P0 |
|
||||
| F-CL-05 | 今天/昨天/明天**快捷切换**(顶部 Segmented 或左右滑动) | 一键切换,切换有过渡动画 | P0 |
|
||||
| F-CL-06 | **日期标记**:月历中每个日期按完成情况标记——全部完成(实心)、部分完成(半填充)、未开始(空心/无标记)、超额完成(特殊标记) | 一眼能看出当月坚持情况 | P0 |
|
||||
| F-CL-07 | **日期详情**:点击某日期,展示当天每个打卡项的"目标次数 / 已完成次数 / 状态" | 过去日期可补打,未来日期只读 | P0 |
|
||||
| F-CL-08 | 日历与打卡**数据同源**:打卡操作在日历视图与打卡列表视图中即时一致 | 任一处打卡,另一处刷新 | P0 |
|
||||
| F-CL-09 | 月历顶部展示**当月完成率**(完成次数 / 应完成次数) | 百分比展示 | P1 |
|
||||
| F-CL-10 | 支持左右**滑动手势**切换月份 | 与按钮切换等价 | P2 |
|
||||
| F-CL-11 | 月视图支持简单的**完成热力**(按完成率深浅着色) | 可选展示,默认关闭 | P2 |
|
||||
|
||||
### 3.3 功能需求 — 清单(F-LS)
|
||||
|
||||
| 编号 | 需求描述 | 验收要点 | 优先级 |
|
||||
| --- | --- | --- | --- |
|
||||
| F-LS-01 | 支持新建**检查清单**:名称、场景标签(出差 / 回老家 / 自定义)、备注 | 名称必填,场景标签可空 | P0 |
|
||||
| F-LS-02 | 清单内可**增删改检查项**,支持排序 | 单条 ≤ 40 字 | P0 |
|
||||
| F-LS-03 | **勾选完成**:点击检查项前的复选框即打勾,已完成项置灰 + 删除线;可取消勾选 | 单击切换,状态即时保存 | P0 |
|
||||
| F-LS-04 | 清单头部显示**进度**:`已完成 / 总数` + 进度条;全部完成时显示完成态 | 全部勾完触发完成提示 | P0 |
|
||||
| F-LS-05 | 清单**全部完成**时播放完成特效(与 F-CK-07 同一套特效组件) | 复用,风格统一 | P1 |
|
||||
| F-LS-06 | **模板**:系统预置常用模板(出差、回老家),一键基于模板生成新清单 | 至少 2 个预置模板 | P1 |
|
||||
| F-LS-07 | **另存为模板**:用户可将自己的清单保存为自定义模板,后续复用 | 自定义模板可删除 | P1 |
|
||||
| F-LS-08 | **复用历史清单**:从历史清单复制生成一份新清单(勾选状态重置) | 原清单保持不变 | P1 |
|
||||
| F-LS-09 | 清单**归档/删除**:完成的清单可归档(不出现在进行中列表),可查看历史 | 删除需二次确认 | P1 |
|
||||
| F-LS-10 | 清单项支持**备注/数量**说明(如"充电器 ×1") | 展示在标题下方,弱色小字 | P2 |
|
||||
| F-LS-11 | 清单支持**一键全部勾选 / 全部取消** | 批量操作入口 | P2 |
|
||||
|
||||
### 3.4 功能需求 — 通用(F-SY)
|
||||
|
||||
| 编号 | 需求描述 | 验收要点 | 优先级 |
|
||||
| --- | --- | --- | --- |
|
||||
| F-SY-01 | 底部 TabBar 新增**「打卡」页**,并作为**默认首页放在最左**;「清单」不单独占 Tab,作为打卡页内的第三个子视图 | 图标风格与现有 TabIcon 一致;启动即进入 `/checkin` | P0 |
|
||||
| F-SY-02 | 「打卡」页内集成 打卡/日历 两个功能,日历放在上半截,默认显示一行为周视图,下拉显示为月视图。 | 日历中选择时间与下部打卡任务联动 | P0 |
|
||||
| F-SY-03 | 所有接口复用现有鉴权(`authRequired`,JWT),数据按 `userId` 隔离 | 未登录跳转 `/login` | P0 |
|
||||
| F-SY-04 | 后端新增 REST 路由(建议 `/api/checkin/*`、`/api/checklist/*`),挂载到 `server/index.js` | 与现有路由风格一致 | P0 |
|
||||
| F-SY-05 | 时间处理统一 **Asia/Shanghai**,前端与后端对"日历日"的判定一致 | 跨零点打卡归属当天 | P0 |
|
||||
| F-SY-06 | 打卡/勾选操作采用**乐观更新**,接口失败时回滚并 Toast 提示 | 失败可见、状态不失真 | P1 |
|
||||
| F-SY-07 | 写接口**幂等/防抖**:同一打卡项 1 秒内重复点击只记一次 | 前端防抖 + 后端唯一约束双保险 | P1 |
|
||||
| F-SY-08 | 页面可见性变化时刷新数据(复用 `useRefreshOnVisible`) | 从后台切回自动同步 | P1 |
|
||||
|
||||
### 3.5 交互与体验需求(U)
|
||||
|
||||
| 编号 | 需求描述 | 验收要点 | 优先级 |
|
||||
| --- | --- | --- | --- |
|
||||
| U-01 | **环形进度组件**:SVG 圆环,背景环 + 进度环,进度变化带 400ms ease-out 动画 | 组件可复用(打卡卡片、清单进度共用) | P0 |
|
||||
| U-02 | **完成特效**:达标瞬间环形闭合 + 光晕/粒子扩散,约 600ms;可重复观看不烦扰 | 不阻塞操作,动画结束自动清理 | P0 |
|
||||
| U-03 | **触感反馈**:完成打卡时轻量震动(Web Vibration API / Tauri) | 固定开启,与特效一同触发 | P1 |
|
||||
| U-04 | 移动端适配:375~430px 视口,复用 `globals.css` 的 `.card` 体系,安全区与底部 TabBar 不遮挡内容 | 真机无横向滚动、无遮挡 | P0 |
|
||||
| U-05 | 空态 / 加载态 / 错误态三态齐全(首次使用引导"创建第一个打卡项") | 空态有明确 CTA | P0 |
|
||||
| U-06 | 打卡按钮足够大(点击热区 ≥ 44×44),符合移动端触控规范 | 误触率低 | P1 |
|
||||
| U-07 | 动效**性能降级**:特效保持轻量(单次 ≤900ms、`pointer-events:none`),不给低端机造成卡顿 | 不卡顿、不阻塞操作 | P1 |
|
||||
| U-08 | 深浅色:跟随现有浅色主题,卡片/分割线风格与首页、监控页一致 | 视觉统一 | P1 |
|
||||
| U-09 | 清单勾选有明确的勾选动画(勾形描边动画) | 反馈即时 | P2 |
|
||||
|
||||
### 3.6 数据需求(D)
|
||||
|
||||
| 编号 | 需求描述 | 验收要点 | 优先级 |
|
||||
| --- | --- | --- | --- |
|
||||
| D-01 | 新增 Prisma 模型:`Habit`(打卡项)、`CheckIn`(打卡记录) | 字段见附录 A | P0 |
|
||||
| D-02 | 新增 Prisma 模型:`CheckList`(清单)、`CheckListItem`(检查项)、`CheckListTemplate`(模板,P1) | 字段见附录 A | P0 |
|
||||
| D-03 | `Habit` / `CheckList` 均带 `userId` 外键,删除用户级联删除 | 数据隔离 | P0 |
|
||||
| D-04 | `CheckIn` 建立 `(habitId, date)` 索引,支持按日期范围查询 | 日历月查询走索引 | P0 |
|
||||
| D-05 | 提供迁移 SQL 并纳入 `prisma migrate deploy` 流程 | 与 V0.1 迁移流程一致 | P0 |
|
||||
| D-06 | 软删除语义:`Habit.archived`、`CheckList.archived`,历史记录保留 | 不物理删除历史 | P1 |
|
||||
|
||||
### 3.7 非功能需求(N)
|
||||
|
||||
| 编号 | 需求描述 | 验收要点 | 优先级 |
|
||||
| --- | --- | --- | --- |
|
||||
| N-01 | 页面首屏数据加载 < 1.5s(内网/正常网络),交互响应 < 300ms(乐观更新) | 真机可感知流畅 | P0 |
|
||||
| N-02 | 兼容 Android WebView(Tauri 打包)与移动端浏览器,不依赖桌面专有 API | 真机验证 | P0 |
|
||||
| N-03 | 单用户数据量按"千级记录/年"设计,无需分表;月查询一次请求返回 | 日历切月无等待感 | P1 |
|
||||
| N-04 | 所有接口 `authRequired`,越权访问返回 401 | 安全基线 | P0 |
|
||||
| N-05 | 前端代码组织遵循现有约定:`src/pages/checkin/*` + `src/components/*` + `server/routes/*` | 结构一致 | P0 |
|
||||
|
||||
### 3.8 异常与边界需求(E)
|
||||
|
||||
| 编号 | 需求描述 | 处理约定 | 优先级 |
|
||||
| --- | --- | --- | --- |
|
||||
| E-01 | 重复点击打卡 | 前端防抖 + 后端唯一约束,只记一次 | P0 |
|
||||
| E-02 | 接口失败 / 断网 | 乐观更新回滚 + Toast 提示,可重试 | P0 |
|
||||
| E-03 | **周期中途新建**打卡项(如周三创建"每周 3 次") | 本期按**完整目标 n** 计算,不按剩余天数折算 | P1 |
|
||||
| E-04 | 每月 n 次在天数不同的月份 | 按**自然月**窗口,不折算 | P0 |
|
||||
| E-05 | 每年 n 次跨年 | 按**自然年**窗口,年末清零 | P0 |
|
||||
| E-06 | 删除打卡项 | 归档(软删除)为主;硬删除需二次确认并提示"历史记录将一并删除" | P1 |
|
||||
| E-07 | 清单项全部删除后清单进度为 0/0 | 显示空清单,不显示 100% | P1 |
|
||||
| E-08 | 跨零点打卡(23:59 → 00:00) | 以**提交时刻的本地日历日**归属 | P0 |
|
||||
|
||||
### 3.9 功能需求 — 版本与升级(F-OT,2026-10-22 追加)
|
||||
|
||||
| 编号 | 需求描述 | 验收要点 | 优先级 |
|
||||
| --- | --- | --- | --- |
|
||||
| F-OT-01 | **OTA 包版本必须与分支对应**:`dev-0.2` 分支上 OTA 包版本应为 `0.2.0-ota` | `GET /api/ota/check/0` 返回 `version = 0.2.0-ota` | P0 |
|
||||
| F-OT-01a | **APK 版本与 OTA 版本同源**:APK(含桌面包)版本为裸版本号 `0.2.0`,无后缀;OTA 版本为同一版本号加 `-ota`。两者都跟代码分支保持一致 | APK 系统信息里 versionName=`0.2.0`(versionCode=2000),OTA manifest=`0.2.0-ota` | P0 |
|
||||
| F-OT-02 | 版本号错位在 CI 阶段就拦住:①`package.json`/`tauri.conf.json`/`Cargo.toml`/`Cargo.lock` 四处必须完全一致且不含 `-ota`;②分支名 `dev-x.y` 与版本主次号不一致时构建失败 | CI 输出明确报错并中断 | P0 |
|
||||
| F-OT-03 | 「我」页 OTA 组内新增**「开发版本」开关**,默认**关闭** | 关闭=按版本号比较;打开=按 build sequence 比较 | P0 |
|
||||
| F-OT-04 | 开关值**持久化在设备端**,重启 App 后保持上次选择 | 杀进程重进仍为上次状态 | P0 |
|
||||
| F-OT-05 | 版本号比较规则:按非数字字符切成数字字段,逐段比较大小(`0.2.0 > 0.1.0`,`0.10 > 0.9`) | 单测覆盖后缀/前缀/缺位/多位段 | P0 |
|
||||
| F-OT-06 | 切换开关后清空上一次的检查结果,避免误判 | 切换即回到「未检查」状态 | P1 |
|
||||
|
||||
---
|
||||
|
||||
## 4. 优先级汇总(本期交付范围)
|
||||
|
||||
- **P0(必须)**:F-CK-01~08、F-CK-11;F-CL-01~08;F-LS-01~04;F-SY-01~05;F-OT-01、F-OT-01a、F-OT-02~05;U-01、U-02、U-04、U-05;D-01~05;N-01、N-02、N-04、N-05;E-01、E-02、E-04、E-05、E-08。
|
||||
- **P1(应该)**:F-CK-09、10、12~14;F-CL-09;F-LS-05~09;F-SY-06~08;F-OT-06;U-03、U-06~08;D-06;N-03;E-03、E-06、E-07。
|
||||
- **P2(可选/延后)**:F-CK-15~17;F-CL-10、11;F-LS-10、11;U-09。
|
||||
|
||||
---
|
||||
|
||||
## 5. 里程碑建议
|
||||
|
||||
| 里程碑 | 内容 |
|
||||
| --- | --- |
|
||||
| **M1 数据与接口** | Prisma 模型 + 迁移;`server/routes/checkin.js`、`checklist.js`;周期进度计算与日期查询 |
|
||||
| **M2 打卡视图** | 环形进度组件 + 打卡卡片列表 + 打卡/撤销/补打 + 完成特效 |
|
||||
| **M3 日历视图** | 月历网格 + 今天/昨天/明天三视图 + 日期标记 + 日期详情 |
|
||||
| **M4 清单视图** | 清单 CRUD + 勾选交互 + 进度条 + 模板与复用 |
|
||||
| **M5 联调打磨** | 真机验证、动效降级、空态/错误态、性能与跨天边界 |
|
||||
|
||||
---
|
||||
|
||||
## 6. 待确认问题(评审时决策)
|
||||
|
||||
| # | 问题 | 建议 |
|
||||
| --- | --- | --- |
|
||||
| 1 | 是否新增第 5 个底部 Tab「打卡」,还是放在首页入口? | ✅ **已决策(2026-10-22)**:「打卡」作为**默认首页放在最左**;原来的「首页」取消,报警/新闻并入宏观 |
|
||||
| 2 | 周起始日是周一还是周日? | 建议周一(国内习惯) |
|
||||
| 3 | 是否允许"明天提前打卡"? | 建议**不允许**(F-CK-11),保持统计口径干净 |
|
||||
| 4 | 删除打卡项时历史是否保留? | 建议默认归档保留,硬删除才清历史 |
|
||||
| 5 | 预置模板具体包含哪些项? | 评审时列出"出差/回老家"各 8~12 条初始项 |
|
||||
| 6 | 完成特效是否可全局关闭? | ✅ **已决策(2026-10-22)**:不提供关闭入口,**特效永远打开**;页面内不再有特效开关 |
|
||||
|
||||
---
|
||||
|
||||
## 附录 A:核心实体草案(供设计阶段参考,非最终表结构)
|
||||
|
||||
```prisma
|
||||
// 打卡项
|
||||
model Habit {
|
||||
id Int @id @default(autoincrement())
|
||||
userId Int
|
||||
name String
|
||||
period String // day | week | month | year
|
||||
target Int @default(1) // 周期内目标次数 n
|
||||
color String @default("#1677ff")
|
||||
icon String @default("")
|
||||
note String @default("")
|
||||
archived Boolean @default(false)
|
||||
sortOrder Int @default(0)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
records CheckIn[]
|
||||
|
||||
@@index([userId, archived])
|
||||
}
|
||||
|
||||
// 打卡记录(一次打卡 = 一条;同日多次为多条)
|
||||
model CheckIn {
|
||||
id Int @id @default(autoincrement())
|
||||
habitId Int
|
||||
habit Habit @relation(fields: [habitId], references: [id], onDelete: Cascade)
|
||||
userId Int
|
||||
date String // 本地日历日 "YYYY-MM-DD" (Asia/Shanghai)
|
||||
seq Int @default(1) // 当日第几次 (1..n, 超额继续累加)
|
||||
note String @default("")
|
||||
source String @default("user") // user | backfill(补打)
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@unique([habitId, date, seq])
|
||||
@@index([userId, date])
|
||||
@@index([habitId, date])
|
||||
}
|
||||
|
||||
// 检查清单
|
||||
model CheckList {
|
||||
id Int @id @default(autoincrement())
|
||||
userId Int
|
||||
title String
|
||||
scene String @default("") // 出差 | 回老家 | 自定义
|
||||
note String @default("")
|
||||
archived Boolean @default(false)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
items CheckListItem[]
|
||||
|
||||
@@index([userId, archived])
|
||||
}
|
||||
|
||||
// 检查项
|
||||
model CheckListItem {
|
||||
id Int @id @default(autoincrement())
|
||||
listId Int
|
||||
list CheckList @relation(fields: [listId], references: [id], onDelete: Cascade)
|
||||
title String
|
||||
note String @default("")
|
||||
done Boolean @default(false)
|
||||
doneAt DateTime?
|
||||
sortOrder Int @default(0)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([listId, sortOrder])
|
||||
}
|
||||
|
||||
// 清单模板(P1)
|
||||
model CheckListTemplate {
|
||||
id Int @id @default(autoincrement())
|
||||
userId Int? // null = 系统预置
|
||||
name String
|
||||
scene String @default("")
|
||||
items Json // [{ title, note }]
|
||||
builtin Boolean @default(false)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
```
|
||||
|
||||
> 说明:周期进度由 `CheckIn` 按周期窗口现场聚合(不冗余计数字段),避免并发与修改 n 后的一致性问题。
|
||||
|
||||
---
|
||||
|
||||
## 附录 B:实现约定(开发期补充,用于消除口径歧义)
|
||||
|
||||
| # | 约定 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| B-1 | **周期窗口** | 日=自然日;周=周一 00:00 ~ 周日 23:59;月=自然月;年=自然年;时区 Asia/Shanghai |
|
||||
| B-2 | **环形进度** | 周期完成度 = 周期内累计打卡次数 / n(每周 n 次:打 1 次=1/n 环;每天 1 次:打 1 次直接闭合) |
|
||||
| B-3 | **日历日期标记** | 用"当日完成度":当日次数 / **日均期望次数**(每天 n 次→n;每周 n 次→ceil(n/7);每月→ceil(n/当月天数);每年→ceil(n/365))。全完成=实心绿点,部分=黄点,超额=紫点,无记录=不显示 |
|
||||
| B-4 | **streak** | 从当前周期往回数连续达标的周期数;**当前周期未结束且未达标不算断签**(跳过继续往前数) |
|
||||
| B-5 | **补打限制** | 只能补打最近 7 天(`BACKFILL_DAYS`),未来日期禁止打卡 |
|
||||
| B-6 | **撤销语义** | 撤销删除本周期内"最近一次"打卡(按 date desc、seq desc 取第一条) |
|
||||
| B-7 | **删除打卡项** | 默认归档(`archived=true`,历史保留);`?hard=1` 才物理删除并清记录 |
|
||||
| B-8 | **接口前缀** | 打卡 `/api/checkin/*`、清单 `/api/checklists/*`,全部 `authRequired`,数据按 `userId` 隔离 |
|
||||
| B-9 | **前端入口** | 底部 Tab 4 个:**打卡(最左,默认选中)** / 宏观 / 监控 / 我;`/` 重定向到 `/checkin`,页内三视图:打卡 / 日历 / 清单。原「首页」取消,报警/新闻并入宏观 |
|
||||
| B-10 | **特效** | 完成特效 + 触感反馈**恒开**,页面内不提供开关(2026-10-22 变更,原 `localStorage iboard_celebrate` 机制已移除) |
|
||||
| B-11 | **OTA 版本** | OTA 包 `version = package.json.version + '-ota'`,必须与分支一致(`dev-0.2` → `0.2.0-ota`),CI 有强校验 |
|
||||
| B-12 | **OTA 判新** | 我的页「开发版本」开关(默认关,持久化 `localStorage.iboard_ota_dev_mode`):关=版本号字段比较,开=build sequence 比较 |
|
||||
|
||||
### 接口清单
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| 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
|
||||
}
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "iboard",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"tauri": "tauri",
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,22 +1,80 @@
|
||||
# iBoard
|
||||
用于收集和整理经济数据,但是以策略为主体重新组织。
|
||||
|
||||
## 版本管理 (V0.1)
|
||||
## 版本管理
|
||||
|
||||
版本号遵循 `主版本.次版本.修订号`,**唯一手动管理点是 `package.json` 的 `version` 字段**(当前 `0.1.0`)。
|
||||
发版时只改这一处,以下位置必须保持一致(改版本时一并核对):
|
||||
### 一句话规则
|
||||
|
||||
**同一个版本号,APK(原生包)裸写,OTA 热更包多一个 `-ota` 后缀。**
|
||||
|
||||
```
|
||||
分支 dev-0.2 → 版本 0.2.0
|
||||
├─ APK / 桌面安装包 versionName = 0.2.0 (无后缀)
|
||||
├─ 前端运行时 APP_VERSION = 0.2.0 (无后缀)
|
||||
└─ OTA 热更包 version = 0.2.0-ota (多一个 -ota)
|
||||
```
|
||||
|
||||
版本号遵循 `主版本.次版本.修订号`,**唯一手动管理点是 `package.json` 的 `version` 字段**(当前 `0.2.0`),
|
||||
该字段只写裸版本号,**不允许带 `-ota`**——`-ota` 由 `scripts/build-ota.mjs` 打包时自动追加。
|
||||
|
||||
版本号必须与所在分支对应:`dev-0.2` 分支上就该是 `0.2.x`。
|
||||
|
||||
### 改版本号时要同步的位置
|
||||
|
||||
| 文件 | 字段 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `package.json` | `version` | **唯一源**,前端 `src/version.js` 与 OTA 打包脚本均读取此值 |
|
||||
| `src-tauri/tauri.conf.json` | `version` | Tauri 应用版本(Android/桌面包版本) |
|
||||
| `src-tauri/tauri.conf.json` | `version` | Tauri 应用版本 → **APK 的 versionName**、桌面安装包版本 |
|
||||
| `src-tauri/Cargo.toml` | `[package].version` | Rust 包版本,需与 tauri.conf.json 一致 |
|
||||
| `src-tauri/Cargo.lock` | `name = "iboard"` 条目 | 同上,随 Cargo.toml 一起改 |
|
||||
|
||||
`scripts/ci/check-version.mjs` 会在 CI 第一段就校验这四处**完全相等**且不含 `-ota`,
|
||||
并校验 `dev-<major>.<minor>` 分支与版本前缀对应;任何一项不满足直接构建失败(`main`/`master` 只跳过分支校验,一致性照样查)。
|
||||
|
||||
### APK 版本号是怎么来的
|
||||
|
||||
`tauri android init` 会按 `src-tauri/tauri.conf.json` 的 `version` 生成
|
||||
`src-tauri/gen/android/app/tauri.properties`(该目录被 gitignore,CI 每次 `android init` 重新生成):
|
||||
|
||||
```
|
||||
tauri.android.versionName = 0.2.0 # 人类可见版本,与 tauri.conf.json 一致
|
||||
tauri.android.versionCode = 2000 # 整数,升级判定用 = major*1000000 + minor*1000 + patch
|
||||
```
|
||||
|
||||
注意:
|
||||
- `versionCode` 必须**单调递增**才能覆盖安装,所以版本号只能往上走(`0.2.0` → 2000 > `0.1.0` → 1000)。
|
||||
- 本地如果 APK 装完显示旧版本号,是 `src-tauri/gen/android` 缓存没刷新,
|
||||
删掉该目录重新 `npx tauri android init` 即可。
|
||||
|
||||
### 其它
|
||||
|
||||
说明:
|
||||
- `src/version.js` 从 `package.json` 取值,前端"我"页与 OTA 检查接口展示的即此版本,不要另写死版本号。
|
||||
- OTA 升级包的 `version` = `<package.json version>-ota`(见 `scripts/build-ota.mjs`),
|
||||
所以版本号错位会直接导致线上 OTA 判新失准。
|
||||
- **`min_binary_version` 与这里的版本号是两件事**:它是 OTA 包要求的最低原生二进制版本
|
||||
(CI 里由 `OTA_MIN_BINARY` 显式指定,当前 `0.1.0`)。前端版本升到 0.2.0 时它不应跟着抬,
|
||||
否则装了 0.1.0 APK 的设备会直接收不到热更。
|
||||
- `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` 验证 → 提交迁移目录。
|
||||
|
||||
- 首页主要是放经济文章
|
||||
- 数据主要是放各种经济数据和图表。
|
||||
- 策略是放按照经济理论组织的相关数据和图表的对比。
|
||||
@@ -105,6 +163,21 @@ node scripts/build-ota.mjs # 打成 tar.gz + 签名 + latest.json
|
||||
> **注意:** Tauri 2 的 dev 模式 (`tauri dev`) 会代理到 Vite dev server, **OTA 不生效**.
|
||||
> OTA 仅在 release/打包构建中可用, 测试请用 `tauri build` 产物.
|
||||
|
||||
### 客户端判新口径(V0.2 新增)
|
||||
|
||||
"我"页 OTA 组内有一个 **开发版本** 开关(默认关闭),切换"怎么算新版本":
|
||||
|
||||
| 开关 | 判新依据 | 适用 |
|
||||
| --- | --- | --- |
|
||||
| 关(默认) | **版本号**逐字段比大小(`0.2.0-ota` > `0.1.0` 才算新) | 生产。旧分支被重建(sequence 变大但版本号更小)时不会把设备拉回旧版 |
|
||||
| 开 | **build sequence**(服务端 sequence 比本地大就算新) | 开发联调。每次构建都能推到设备 |
|
||||
|
||||
- 开关值持久化在设备端 `localStorage.iboard_ota_dev_mode`(`'1'`/`'0'`),默认 `'0'`。
|
||||
- 版本比较在 `src/utils/version.js`(纯函数,`src/api/ota.js` 统一封装两种模式)。
|
||||
- **`min_binary_version` 与应用版本是两件事**:前者是前端 bundle 要求的最低原生二进制版本,
|
||||
当前 APK 原生层仍是 `0.1.0`,所以 CI 里固定传 `--min-binary 0.1.0`(见 `docker-build.yml` 的
|
||||
`OTA_MIN_BINARY`)。只改前端版本号时**不要**跟着抬这个值,否则旧 APK 会收不到更新。
|
||||
|
||||
### Web 端
|
||||
|
||||
Web 浏览器访问 (同源) 时, 服务端返回简化版 manifest, 仅显示"有新版本请刷新".
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
#!/usr/bin/env node
|
||||
// CI 专用: 校验版本号四处一致 + 与分支名一致
|
||||
//
|
||||
// 背景:
|
||||
// 版本号只允许一个手工管理点 = package.json 的 version, 其余位置全部派生:
|
||||
// - APK / 桌面安装包 version = <version> (裸版本号, 无后缀)
|
||||
// - OTA 热更包 version = <version>-ota (多一个 -ota 标记)
|
||||
// - 前端运行时 APP_VERSION = <version>
|
||||
// 任何一处漏改都会让线上版本号、APK 版本、OTA 判新三者对不上, 且很难在客户端发现。
|
||||
//
|
||||
// 校验分两段:
|
||||
// A. 一致性 (任何分支都查): package.json ↔ tauri.conf.json ↔ Cargo.toml ↔ Cargo.lock
|
||||
// 并要求 package.json 的 version 不含 "-ota" (裸版本, OTA 后缀由打包脚本追加)
|
||||
// B. 分支对应 (仅 dev-<版本> 分支):
|
||||
// dev-<major>.<minor> → version 必须以 <major>.<minor> 开头
|
||||
// dev-<major> → version 必须以 <major>. 开头
|
||||
// main / master / 其它 → 跳过
|
||||
//
|
||||
// 环境变量:
|
||||
// GITHUB_REF_NAME - Gitea Actions 提供的分支名 (本地运行可用 BRANCH 覆盖)
|
||||
//
|
||||
// 退出码: 0 通过 / 1 不一致
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
const ROOT = path.resolve(__dirname, '..', '..')
|
||||
|
||||
const branch = process.env.GITHUB_REF_NAME || process.env.BRANCH || ''
|
||||
|
||||
function readJson(rel) {
|
||||
return JSON.parse(fs.readFileSync(path.join(ROOT, rel), 'utf8'))
|
||||
}
|
||||
|
||||
// Cargo.toml 的 [package].version 位于文件开头, 取第一个行首 version = "x"
|
||||
function readCargoTomlVersion() {
|
||||
const text = fs.readFileSync(path.join(ROOT, 'src-tauri/Cargo.toml'), 'utf8')
|
||||
const m = /^version\s*=\s*"([^"]+)"/m.exec(text)
|
||||
return m ? m[1] : null
|
||||
}
|
||||
|
||||
// Cargo.lock 里挑出 name = "iboard" 那个包的 version
|
||||
function readCargoLockVersion() {
|
||||
const text = fs.readFileSync(path.join(ROOT, 'src-tauri/Cargo.lock'), 'utf8')
|
||||
const blocks = text.split(/^\[\[package\]\]$/m)
|
||||
for (const b of blocks) {
|
||||
if (/^name\s*=\s*"iboard"$/m.test(b)) {
|
||||
const m = /^version\s*=\s*"([^"]+)"/m.exec(b)
|
||||
if (m) return m[1]
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const sources = [
|
||||
{ file: 'package.json', version: String(readJson('package.json').version || '') },
|
||||
{ file: 'src-tauri/tauri.conf.json', version: String(readJson('src-tauri/tauri.conf.json').version || '') },
|
||||
{ file: 'src-tauri/Cargo.toml', version: readCargoTomlVersion() },
|
||||
{ file: 'src-tauri/Cargo.lock (iboard)', version: readCargoLockVersion() },
|
||||
]
|
||||
|
||||
const version = sources[0].version
|
||||
const otaVersion = version + '-ota'
|
||||
|
||||
console.log('[check-version] 分支: ', branch || '(未知)')
|
||||
for (const s of sources) {
|
||||
console.log(`[check-version] ${s.file.padEnd(28)} ${s.version || '(缺失)'}`)
|
||||
}
|
||||
console.log('[check-version] 产物版本: APK =', version, '/ OTA =', otaVersion)
|
||||
|
||||
let failed = false
|
||||
|
||||
// ---- A. 四处一致性 + 裸版本号 ----
|
||||
if (/-ota$/i.test(version)) {
|
||||
console.error('')
|
||||
console.error(`[check-version] package.json 的 version 不能带 "-ota": ${version}`)
|
||||
console.error('[check-version] OTA 后缀由 scripts/build-ota.mjs 自动追加, 请只写裸版本号 (如 0.2.0)')
|
||||
failed = true
|
||||
}
|
||||
|
||||
const mismatched = sources.filter((s) => s.version !== version)
|
||||
if (mismatched.length) {
|
||||
console.error('')
|
||||
console.error('============================================================')
|
||||
console.error('[check-version] 版本号不一致, 构建中止')
|
||||
console.error('============================================================')
|
||||
for (const s of mismatched) {
|
||||
console.error(` ${s.file}: ${s.version || '(缺失)'} ≠ package.json: ${version}`)
|
||||
}
|
||||
console.error('')
|
||||
console.error(' 规则: APK / 桌面包版本 = 裸版本号 (无后缀);')
|
||||
console.error(' OTA 热更包版本 = 同一个版本号 + "-ota"。')
|
||||
console.error(' 改版本时四处必须一起改 (见 readme「版本管理」)。')
|
||||
failed = true
|
||||
}
|
||||
|
||||
// ---- B. 分支对应 ----
|
||||
const m = /^dev-(\d+)(?:\.(\d+))?$/.exec(branch)
|
||||
if (!m) {
|
||||
console.log('[check-version] 非 dev-<版本> 分支, 跳过分支对应校验')
|
||||
} else {
|
||||
const want = m[2] === undefined ? `${m[1]}.` : `${m[1]}.${m[2]}`
|
||||
if (!version.startsWith(want)) {
|
||||
console.error('')
|
||||
console.error(`[check-version] 版本与分支不一致: 分支 "${branch}" 要求版本以 ${want} 开头, 实际是 ${version}`)
|
||||
console.error('[check-version] 请修改 package.json 的 version, 并同步 tauri.conf.json / Cargo.toml / Cargo.lock')
|
||||
failed = true
|
||||
} else {
|
||||
console.log(`[check-version] 分支一致 (${branch} ↔ ${version})`)
|
||||
}
|
||||
}
|
||||
|
||||
if (failed) process.exit(1)
|
||||
console.log('[check-version] 全部通过')
|
||||
@@ -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
|
||||
Generated
+1
-1
@@ -1456,7 +1456,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "iboard"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "iboard"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
description = "iBoard - 经济数据与策略"
|
||||
authors = ["iBoard Team"]
|
||||
edition = "2021"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "iBoard",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"identifier": "com.iboard.app",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
|
||||
+11
-7
@@ -1,19 +1,21 @@
|
||||
import { Routes, Route, Navigate, useLocation } from 'react-router-dom'
|
||||
import MobileShell from './layouts/MobileShell.jsx'
|
||||
import LoginPage from './pages/LoginPage.jsx'
|
||||
import HomePage from './pages/HomePage.jsx'
|
||||
import NewsDetail from './pages/news/NewsDetail.jsx'
|
||||
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 自动回首页
|
||||
// 默认落地页: 打卡 (V0.2 起打卡是第一个 Tab 且为默认选中)
|
||||
const HOME_PATH = '/checkin'
|
||||
|
||||
// 已登录访问 /login 自动回默认页
|
||||
function RedirectIfAuthed({ children }) {
|
||||
const { user, loading } = useAuth()
|
||||
if (loading) return null
|
||||
if (user) return <Navigate to="/" replace />
|
||||
if (user) return <Navigate to={HOME_PATH} replace />
|
||||
return children
|
||||
}
|
||||
|
||||
@@ -31,14 +33,16 @@ export default function App() {
|
||||
<Routes>
|
||||
<Route path="/login" element={<RedirectIfAuthed><LoginPage /></RedirectIfAuthed>} />
|
||||
<Route element={<RequireAuth><MobileShell /></RequireAuth>}>
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route path="/news/:id" element={<NewsDetail />} />
|
||||
{/* 打卡为默认首页 */}
|
||||
<Route path="/" element={<Navigate to={HOME_PATH} replace />} />
|
||||
<Route path="/checkin" element={<CheckinPage />} />
|
||||
{/* 宏观: 油价 / 经济数据 / 策略研究 / 经济文章 / 报警 / 新闻 (V0.2 原首页并入) */}
|
||||
<Route path="/macro/*" element={<MacroPage />} />
|
||||
<Route path="/monitor" element={<MonitorPage />} />
|
||||
<Route path="/monitor/server/:uuid" element={<ServerDetail />} />
|
||||
<Route path="/me" element={<MePage />} />
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
<Route path="*" element={<Navigate to={HOME_PATH} replace />} />
|
||||
</Routes>
|
||||
)
|
||||
}
|
||||
@@ -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' }),
|
||||
}
|
||||
+99
-17
@@ -5,7 +5,7 @@
|
||||
// 设计原则:
|
||||
// - Web 端只能"查 + 提示", 不能真下载应用, 避免误导
|
||||
// - Tauri 端走 hotswap 插件的官方 API, 签名校验/回滚/资源替换全交给插件
|
||||
import { getApiBase, getToken } from './client.js'
|
||||
import { getApiBase } from './client.js'
|
||||
import { APP_VERSION, BUILD_ID } from '../version.js'
|
||||
|
||||
// 判断是否在 Tauri WebView 内 (有 __TAURI_INTERNALS__ 即 Tauri 环境)
|
||||
@@ -33,6 +33,47 @@ export function initOta() {
|
||||
.catch((e) => console.warn('[ota] notifyReady failed:', e))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 判新模式 (V0.2)
|
||||
// 「开发版本」开关 (我的页 OTA 组内, 默认关闭), 值持久化在设备端 localStorage:
|
||||
// 开关 关闭 (默认 / 生产口径): 用**版本号**判新 —— 取服务端 manifest 的 version,
|
||||
// 与当前运行版本按数字字段逐段比较大小 (0.2.0 > 0.1.0)。
|
||||
// 这样即使某个旧版本分支被重新构建 (sequence 变大但版本号更小), 也不会把新设备拉回旧版。
|
||||
// 开关 打开 (开发口径): 用 **build sequence** 判新 —— 直接走插件 checkUpdate(),
|
||||
// 只要服务端 sequence 比本地大就算新, 每次提交都能推到设备上, 便于联调。
|
||||
// ---------------------------------------------------------------------------
|
||||
const OTA_DEV_MODE_KEY = 'iboard_ota_dev_mode'
|
||||
|
||||
export function getOtaDevMode() {
|
||||
try {
|
||||
return localStorage.getItem(OTA_DEV_MODE_KEY) === '1'
|
||||
} catch { /* ignore */ }
|
||||
return false
|
||||
}
|
||||
|
||||
export function setOtaDevMode(v) {
|
||||
try { localStorage.setItem(OTA_DEV_MODE_KEY, v ? '1' : '0') } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
// 版本号 → 数字字段数组 / 比较函数 (纯函数放在 utils, 便于单测)
|
||||
import { compareVersion, parseVersionFields } from '../utils/version.js'
|
||||
|
||||
export { compareVersion, parseVersionFields }
|
||||
|
||||
// 拉服务端最新 manifest (不分平台)
|
||||
// /api/ota/check/0: current_sequence 传 0, 只要有 OTA 包就一定返回 200 + manifest
|
||||
export async function fetchOtaManifest() {
|
||||
try {
|
||||
const res = await fetch(resolveUrl('/api/ota/check/0'), { method: 'GET' })
|
||||
if (res.status === 204) return null
|
||||
if (!res.ok) return null
|
||||
return await res.json()
|
||||
} catch (e) {
|
||||
console.warn('[ota] manifest 拉取失败:', e)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// 检查更新
|
||||
// 返回统一结构, 调用方不关心是 Tauri 还是 web:
|
||||
// {
|
||||
@@ -40,34 +81,71 @@ export function initOta() {
|
||||
// reason: 'no_package' | 'same_version' | 'ota_disabled' | 'unsupported' | 'ok',
|
||||
// info: { version, sequence, notes, mandatory, bundle_size }
|
||||
// }
|
||||
export async function checkOtaUpdate() {
|
||||
// opts.devMode: true=按 build sequence 判新, false=按版本号判新 (默认)
|
||||
export async function checkOtaUpdate(opts) {
|
||||
const devMode = !!(opts && opts.devMode)
|
||||
if (!inTauri) {
|
||||
return checkOtaUpdateWeb()
|
||||
return checkOtaUpdateWeb(devMode)
|
||||
}
|
||||
return checkOtaUpdateTauri()
|
||||
return checkOtaUpdateTauri(devMode)
|
||||
}
|
||||
|
||||
async function checkOtaUpdateTauri() {
|
||||
// 走 hotswap 插件 (内部会带 sequence / platform / arch 等参数)
|
||||
const { checkUpdate } = await import('tauri-plugin-hotswap-api')
|
||||
const r = await checkUpdate()
|
||||
if (!r.available) {
|
||||
return { available: false, reason: 'no_package', info: r }
|
||||
// 取"当前运行版本": 已应用 OTA 覆盖包则用覆盖包版本, 否则用内置包版本
|
||||
async function currentRunningVersion() {
|
||||
try {
|
||||
const v = await getOtaVersionInfo()
|
||||
if (v?.active && v.version) return v.version
|
||||
} catch { /* ignore */ }
|
||||
return APP_VERSION
|
||||
}
|
||||
|
||||
// 版本号口径判新 (Tauri + Web 共用): 服务端 version 比当前运行版本大才算新
|
||||
async function checkByVersion() {
|
||||
const [manifest, current] = await Promise.all([fetchOtaManifest(), currentRunningVersion()])
|
||||
if (!manifest || !manifest.version) {
|
||||
return { available: false, reason: 'no_package', info: null }
|
||||
}
|
||||
if (compareVersion(manifest.version, current) <= 0) {
|
||||
return { available: false, reason: 'same_version', info: manifest }
|
||||
}
|
||||
return {
|
||||
available: true,
|
||||
reason: 'ok',
|
||||
info: {
|
||||
version: r.version,
|
||||
sequence: r.sequence,
|
||||
notes: r.notes,
|
||||
mandatory: r.mandatory,
|
||||
bundle_size: r.bundle_size,
|
||||
version: manifest.version,
|
||||
sequence: manifest.sequence,
|
||||
notes: manifest.notes || '',
|
||||
mandatory: !!manifest.mandatory,
|
||||
bundle_size: manifest.bundle_size || 0,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async function checkOtaUpdateWeb() {
|
||||
async function checkOtaUpdateTauri(devMode) {
|
||||
// 开发口径: sequence 判新, 直接走插件 (插件内部用当前 sequence 请求 /api/ota/check/:seq)
|
||||
if (devMode) {
|
||||
const { checkUpdate } = await import('tauri-plugin-hotswap-api')
|
||||
const r = await checkUpdate()
|
||||
if (!r.available) {
|
||||
return { available: false, reason: 'no_package', info: r }
|
||||
}
|
||||
return {
|
||||
available: true,
|
||||
reason: 'ok',
|
||||
info: {
|
||||
version: r.version,
|
||||
sequence: r.sequence,
|
||||
notes: r.notes,
|
||||
mandatory: r.mandatory,
|
||||
bundle_size: r.bundle_size,
|
||||
},
|
||||
}
|
||||
}
|
||||
// 生产口径: 版本号判新
|
||||
return checkByVersion()
|
||||
}
|
||||
|
||||
async function checkOtaUpdateWeb(devMode) {
|
||||
// Web 端不能下载, 只能拿到版本号/构建号做比对, 提示用户刷新
|
||||
try {
|
||||
const res = await fetch(resolveUrl('/api/ota/manifest-web'), { method: 'GET' })
|
||||
@@ -76,7 +154,11 @@ async function checkOtaUpdateWeb() {
|
||||
}
|
||||
const data = await res.json()
|
||||
if (!data.available) return { available: false, reason: 'no_package', info: data }
|
||||
if (data.build_id && data.build_id === BUILD_ID) {
|
||||
// Web 端拿不到本地 sequence:
|
||||
// 开发口径 (devMode) → 只要服务端版本与本地不同就算新 (近似 sequence 语义, 含回退)
|
||||
// 生产口径 → 严格比较版本号大小, 只有更高版本才算新
|
||||
const cmp = compareVersion(data.version, APP_VERSION)
|
||||
if (devMode ? cmp === 0 : cmp <= 0) {
|
||||
return { available: false, reason: 'same_version', info: data }
|
||||
}
|
||||
return {
|
||||
|
||||
@@ -4,23 +4,23 @@ import TabIcon from './TabIcon.jsx'
|
||||
|
||||
// 底部 Tab 配置: key 既是路由前缀, 又是 icon 名
|
||||
// 使用 antd-mobile TabBar: 自带 safe-area 处理, 仿原生 iOS/Android 风格
|
||||
// V0.2: 打卡置最左并为默认 Tab; 原「首页」的报警/新闻并入「宏观」, 不再有首页 Tab
|
||||
const TABS = [
|
||||
{ key: '/', name: 'home', label: '首页' },
|
||||
{ key: '/checkin', name: 'checkin', label: '打卡' },
|
||||
{ key: '/macro', name: 'macro', label: '宏观' },
|
||||
{ key: '/monitor', name: 'monitor', label: '监控' },
|
||||
{ key: '/me', name: 'me', label: '我' },
|
||||
]
|
||||
|
||||
// 根据当前路径计算激活的 tab key (精确匹配 '/' , 其他前缀匹配)
|
||||
// 根据当前路径计算激活的 tab key (精确匹配 + 前缀匹配)
|
||||
function activeKeyFromPath(pathname) {
|
||||
for (const t of TABS) {
|
||||
if (t.key === '/') {
|
||||
if (pathname === '/') return t.key
|
||||
} else if (pathname === t.key || pathname.startsWith(t.key + '/')) {
|
||||
if (pathname === t.key || pathname.startsWith(t.key + '/')) {
|
||||
return t.key
|
||||
}
|
||||
}
|
||||
return '/'
|
||||
// 未知路径兜底到第一个 Tab (打卡)
|
||||
return TABS[0].key
|
||||
}
|
||||
|
||||
// 移动端底部 Tab 导航: antd-mobile TabBar, 内置安全区
|
||||
@@ -46,4 +46,4 @@ export default function BottomNav() {
|
||||
))}
|
||||
</TabBar>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
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 主色
|
||||
// V0.2 设计变更: 去掉特效开关, 达标特效**恒开** (没有任何关闭入口)
|
||||
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
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
// 轻量触感反馈 (Web Vibration API; Tauri Android WebView 支持)
|
||||
export function haptic(ms = 18) {
|
||||
try {
|
||||
if (typeof navigator !== 'undefined' && navigator.vibrate) navigator.vibrate(ms)
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
@@ -2,13 +2,7 @@
|
||||
// props: name 字符串, active 布尔
|
||||
// 返回对应的 24x24 SVG 节点
|
||||
const ICONS = {
|
||||
home: (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">
|
||||
<path d="M3 11l9-7 9 7" />
|
||||
<path d="M5 10v10h14V10" />
|
||||
{active ? <path d="M10 20v-5h4v5" fill="currentColor" stroke="none" /> : null}
|
||||
</svg>
|
||||
),
|
||||
// V0.2: 打卡移到最左, 原「首页」已并入宏观, home 图标不再使用
|
||||
macro: (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="3" width="7" height="9" rx="1" fill={active ? 'currentColor' : 'none'} fillOpacity={active ? 0.15 : 0} />
|
||||
@@ -22,6 +16,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} />
|
||||
@@ -34,4 +37,4 @@ export default function TabIcon({ name, active }) {
|
||||
const render = ICONS[name]
|
||||
if (!render) return null
|
||||
return <span className="tab-icon">{render(!!active)}</span>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,231 +0,0 @@
|
||||
import { useEffect, useState, useRef, useCallback } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { NavBar, Card, Empty, DotLoading, Badge } from 'antd-mobile'
|
||||
import { api } from '../api/client.js'
|
||||
import SwipeTabs from '../components/SwipeTabs.jsx'
|
||||
import useRefreshOnVisible from '../hooks/useRefreshOnVisible.js'
|
||||
|
||||
// 首页: 报警模块 + 新闻模块
|
||||
// - 报警: World Bank 与妙想 MCP 跨源同 (指标, 周期) 数据差异超阈值 (按时间倒序)
|
||||
// - 新闻: mx_finance_search_news 每日 3 点拉取国内外经济政治新闻 (按时间倒序)
|
||||
// - 两个模块均无限滚动, 触底自动加载下一页
|
||||
function fmtDateTime(s) {
|
||||
if (!s) return ''
|
||||
const d = new Date(s)
|
||||
if (isNaN(d.getTime())) return s
|
||||
const pad = (n) => String(n).padStart(2, '0')
|
||||
return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate()) + ' ' + pad(d.getHours()) + ':' + pad(d.getMinutes())
|
||||
}
|
||||
function fmtDate(s) {
|
||||
if (!s) return ''
|
||||
const d = new Date(s)
|
||||
if (isNaN(d.getTime())) return s
|
||||
const pad = (n) => String(n).padStart(2, '0')
|
||||
return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate())
|
||||
}
|
||||
|
||||
export default function HomePage() {
|
||||
const [active, setActive] = useState('news') // 默认选中新闻
|
||||
const [alertCount, setAlertCount] = useState(0)
|
||||
const handleAlertCount = useCallback((n) => setAlertCount(n), [])
|
||||
|
||||
const tabs = [
|
||||
{
|
||||
key: 'alerts',
|
||||
label: (
|
||||
<span className="tab-label">
|
||||
报警
|
||||
{alertCount > 0 && <Badge content={alertCount} />}
|
||||
</span>
|
||||
),
|
||||
render: () => <AlertSection onCount={handleAlertCount} />,
|
||||
},
|
||||
{ key: 'news', label: '新闻', render: () => <NewsSection /> },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="page-with-navbar">
|
||||
<NavBar back={null} />
|
||||
<div className="home-content">
|
||||
<SwipeTabs tabs={tabs} active={active} onChange={setActive} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 报警模块
|
||||
function AlertSection({ onCount }) {
|
||||
const [items, setItems] = useState([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [done, setDone] = useState(false)
|
||||
const cursorRef = useRef(null)
|
||||
const sentinelRef = useRef(null)
|
||||
const inFlightRef = useRef(false)
|
||||
|
||||
const fetchPage = useCallback(async () => {
|
||||
if (inFlightRef.current || done) return
|
||||
inFlightRef.current = true
|
||||
setLoading(true)
|
||||
try {
|
||||
const url = '/alerts?limit=10' + (cursorRef.current ? '&cursor=' + cursorRef.current : '')
|
||||
const data = await api(url)
|
||||
setItems((prev) => prev.concat(data.items || []))
|
||||
onCount?.(data.total ?? (data.items?.length || 0))
|
||||
cursorRef.current = data.nextCursor
|
||||
if (!data.nextCursor) setDone(true)
|
||||
} catch (e) {
|
||||
console.error('alerts load error', e)
|
||||
setDone(true)
|
||||
} finally {
|
||||
inFlightRef.current = false
|
||||
setLoading(false)
|
||||
}
|
||||
}, [done, onCount])
|
||||
|
||||
useEffect(() => { fetchPage() }, [])
|
||||
// 后台回前台: 重置游标重拉第一页, 新数据到了再更新视图
|
||||
const reload = useCallback(async () => {
|
||||
if (inFlightRef.current) return
|
||||
inFlightRef.current = true
|
||||
setLoading(true)
|
||||
try {
|
||||
const data = await api('/alerts?limit=10')
|
||||
setItems(data.items || [])
|
||||
onCount?.(data.total ?? (data.items?.length || 0))
|
||||
cursorRef.current = data.nextCursor
|
||||
setDone(!data.nextCursor)
|
||||
} catch (e) {
|
||||
console.error('alerts reload error', e)
|
||||
setDone(true)
|
||||
} finally {
|
||||
inFlightRef.current = false
|
||||
setLoading(false)
|
||||
}
|
||||
}, [onCount])
|
||||
useRefreshOnVisible(reload)
|
||||
useEffect(() => {
|
||||
if (done || items.length === 0) return
|
||||
const el = sentinelRef.current
|
||||
if (!el) return
|
||||
const io = new IntersectionObserver((entries) => {
|
||||
if (entries[0].isIntersecting) fetchPage()
|
||||
}, { rootMargin: '200px' })
|
||||
io.observe(el)
|
||||
return () => io.disconnect()
|
||||
}, [items.length, done, fetchPage])
|
||||
|
||||
return (
|
||||
<section className="home-section">
|
||||
<div className="section-title">报警</div>
|
||||
{items.length === 0 && !loading ? (
|
||||
<Empty description="暂无报警" />
|
||||
) : (
|
||||
<div className="section-list">
|
||||
{items.map((a) => (
|
||||
<Card key={a.id} className="alert-card">
|
||||
<div className="alert-title">
|
||||
<span className="alert-name">{a.indicatorName}</span>
|
||||
<span className="alert-period">{a.period}</span>
|
||||
</div>
|
||||
<div className="alert-diff">
|
||||
{a.sourceA} = {a.valueA} vs {a.sourceB} = {a.valueB}
|
||||
<span className="alert-ratio"> 差异 {(a.diffRatio * 100).toFixed(1)}%</span>
|
||||
</div>
|
||||
<div className="alert-time">{fmtDateTime(a.createdAt)}</div>
|
||||
</Card>
|
||||
))}
|
||||
<div ref={sentinelRef} className="scroll-sentinel">
|
||||
{loading ? <DotLoading /> : (done ? '— 已加载全部 —' : '')}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
// 新闻模块
|
||||
function NewsSection() {
|
||||
const [items, setItems] = useState([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [done, setDone] = useState(false)
|
||||
const cursorRef = useRef(null)
|
||||
const sentinelRef = useRef(null)
|
||||
const inFlightRef = useRef(false)
|
||||
const navigate = useNavigate()
|
||||
|
||||
const fetchPage = useCallback(async () => {
|
||||
if (inFlightRef.current || done) return
|
||||
inFlightRef.current = true
|
||||
setLoading(true)
|
||||
try {
|
||||
const url = '/news?limit=10' + (cursorRef.current ? '&cursor=' + encodeURIComponent(cursorRef.current) : '')
|
||||
const data = await api(url)
|
||||
setItems((prev) => prev.concat(data.items || []))
|
||||
cursorRef.current = data.nextCursor
|
||||
if (!data.nextCursor) setDone(true)
|
||||
} catch (e) {
|
||||
console.error('news load error', e)
|
||||
setDone(true)
|
||||
} finally {
|
||||
inFlightRef.current = false
|
||||
setLoading(false)
|
||||
}
|
||||
}, [done])
|
||||
|
||||
useEffect(() => { fetchPage() }, [])
|
||||
// 后台回前台: 重置游标重拉第一页, 新数据到了再更新视图
|
||||
const reload = useCallback(async () => {
|
||||
if (inFlightRef.current) return
|
||||
inFlightRef.current = true
|
||||
setLoading(true)
|
||||
try {
|
||||
const data = await api('/news?limit=10')
|
||||
setItems(data.items || [])
|
||||
cursorRef.current = data.nextCursor
|
||||
setDone(!data.nextCursor)
|
||||
} catch (e) {
|
||||
console.error('news reload error', e)
|
||||
setDone(true)
|
||||
} finally {
|
||||
inFlightRef.current = false
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
useRefreshOnVisible(reload)
|
||||
useEffect(() => {
|
||||
if (done || items.length === 0) return
|
||||
const el = sentinelRef.current
|
||||
if (!el) return
|
||||
const io = new IntersectionObserver((entries) => {
|
||||
if (entries[0].isIntersecting) fetchPage()
|
||||
}, { rootMargin: '200px' })
|
||||
io.observe(el)
|
||||
return () => io.disconnect()
|
||||
}, [items.length, done, fetchPage])
|
||||
|
||||
return (
|
||||
<section className="home-section">
|
||||
<div className="section-title">新闻</div>
|
||||
{items.length === 0 && !loading ? (
|
||||
<Empty description="暂无新闻" />
|
||||
) : (
|
||||
<div className="section-list">
|
||||
{items.map((n) => (
|
||||
<Card key={n.id} className="news-card" onClick={() => navigate('/news/' + n.id)}>
|
||||
<div className="news-title">{n.title}</div>
|
||||
{n.summary && <div className="news-summary">{n.summary}</div>}
|
||||
<div className="news-meta">
|
||||
<span className="news-source">{n.source || '未知来源'}</span>
|
||||
<span className="news-time">{fmtDate(n.publishedAt)}</span>
|
||||
<span className="news-rank">#{n.impactRank}</span>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
<div ref={sentinelRef} className="scroll-sentinel">
|
||||
{loading ? <DotLoading /> : (done ? '— 已加载全部 —' : '')}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
+25
-9
@@ -1,25 +1,32 @@
|
||||
import { useState } from 'react'
|
||||
import { useNavigate, useLocation, Routes, Route, Navigate } from 'react-router-dom'
|
||||
import { NavBar } from 'antd-mobile'
|
||||
import { useNavigate, useLocation, Routes, Route } from 'react-router-dom'
|
||||
import { Badge, NavBar } from 'antd-mobile'
|
||||
import SwipeTabs from '../components/SwipeTabs.jsx'
|
||||
import DataView from './macro/DataView.jsx'
|
||||
import StrategyView from './macro/StrategyView.jsx'
|
||||
import ArticlesView from './macro/ArticlesView.jsx'
|
||||
import ArticleDetail from './macro/ArticleDetail.jsx'
|
||||
import OilPage from './macro/OilPage.jsx'
|
||||
import AlertsView from './macro/AlertsView.jsx'
|
||||
import NewsView from './macro/NewsView.jsx'
|
||||
import NewsDetail from './news/NewsDetail.jsx'
|
||||
|
||||
// 宏观: 顶栏 4 tab (油价 / 经济数据 / 策略研究 / 经济文章), 默认 油价
|
||||
// 宏观: 顶栏 6 tab, 默认 油价
|
||||
// /macro → 显示 oil tab (本组件 activeFromPath 决定)
|
||||
// /macro/oil → 油价 (新增)
|
||||
// /macro/oil → 油价
|
||||
// /macro/data → 经济数据
|
||||
// /macro/strategy → 策略研究
|
||||
// /macro/articles → 经济文章
|
||||
// /macro/articles/:slug → 文章详情
|
||||
// /macro/alerts → 报警 (V0.2: 原「首页」tab 合并进来)
|
||||
// /macro/news → 新闻 (V0.2: 原「首页」tab 合并进来)
|
||||
// 详情: /macro/data/:name · /macro/strategy/:name · /macro/articles/:slug · /macro/news/:id
|
||||
const TABS = [
|
||||
{ key: 'oil', label: '油价', path: '/macro/oil' },
|
||||
{ key: 'data', label: '经济数据', path: '/macro/data' },
|
||||
{ key: 'strategy', label: '策略研究', path: '/macro/strategy' },
|
||||
{ key: 'articles', label: '经济文章', path: '/macro/articles' },
|
||||
{ key: 'alerts', label: '报警', path: '/macro/alerts' },
|
||||
{ key: 'news', label: '新闻', path: '/macro/news' },
|
||||
]
|
||||
|
||||
function activeFromPath(pathname) {
|
||||
@@ -27,7 +34,9 @@ function activeFromPath(pathname) {
|
||||
if (pathname.startsWith('/macro/articles')) return 'articles'
|
||||
if (pathname.startsWith('/macro/strategy')) return 'strategy'
|
||||
if (pathname.startsWith('/macro/data')) return 'data'
|
||||
// 父路由 /macro 或 / 走 Tab 列表第一个 (油价)
|
||||
if (pathname.startsWith('/macro/alerts')) return 'alerts'
|
||||
if (pathname.startsWith('/macro/news')) return 'news'
|
||||
// 父路由 /macro 走 Tab 列表第一个 (油价)
|
||||
return 'oil'
|
||||
}
|
||||
|
||||
@@ -35,15 +44,21 @@ export default function MacroPage() {
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
const active = activeFromPath(location.pathname)
|
||||
// 报警角标: 由 AlertsView 回传总数
|
||||
const [alertCount, setAlertCount] = useState(0)
|
||||
|
||||
const tabs = TABS.map((t) => ({
|
||||
key: t.key,
|
||||
label: t.label,
|
||||
label: t.key === 'alerts'
|
||||
? <span className="tab-label">报警{alertCount > 0 && <Badge content={alertCount} />}</span>
|
||||
: t.label,
|
||||
render: () => {
|
||||
if (t.key === 'oil') return <OilPage />
|
||||
if (t.key === 'data') return <DataView />
|
||||
if (t.key === 'strategy') return <StrategyView />
|
||||
if (t.key === 'articles') return <ArticlesView />
|
||||
if (t.key === 'alerts') return <AlertsView onCount={setAlertCount} />
|
||||
if (t.key === 'news') return <NewsView />
|
||||
return null
|
||||
},
|
||||
}))
|
||||
@@ -53,13 +68,14 @@ export default function MacroPage() {
|
||||
if (next) navigate(next.path)
|
||||
}
|
||||
|
||||
// 详情页 /macro/articles/:slug 和 /macro/data/:name 单独走 React Router 渲染, 不参与滑屏
|
||||
if (location.pathname.match(/^\/macro\/(articles|data|strategy)\/[^/]+$/)) {
|
||||
// 详情页单独走 React Router 渲染, 不参与滑屏
|
||||
if (location.pathname.match(/^\/macro\/(articles|data|strategy|news)\/[^/]+$/)) {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="data/:name" element={<DataView />} />
|
||||
<Route path="strategy/:name" element={<StrategyView />} />
|
||||
<Route path="articles/:slug" element={<ArticleDetail />} />
|
||||
<Route path="news/:id" element={<NewsDetail />} />
|
||||
</Routes>
|
||||
)
|
||||
}
|
||||
|
||||
+31
-3
@@ -1,15 +1,17 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Avatar, Button, List, NavBar, ProgressBar, Toast } from 'antd-mobile'
|
||||
import { Avatar, Button, List, NavBar, ProgressBar, Switch, Toast } from 'antd-mobile'
|
||||
import { useAuth } from '../auth/AuthContext.jsx'
|
||||
import { APP_VERSION, BUILD_ID } from '../version.js'
|
||||
import {
|
||||
applyOtaUpdate,
|
||||
checkOtaUpdate,
|
||||
getCurrentVersionInfo,
|
||||
getOtaDevMode,
|
||||
getOtaVersionInfo,
|
||||
reloadApp,
|
||||
rollbackOta,
|
||||
setOtaDevMode,
|
||||
} from '../api/ota.js'
|
||||
|
||||
// "我" 页: 顶部 NavBar + 用户头像卡片 + 信息列表 + OTA 升级区
|
||||
@@ -29,6 +31,8 @@ export default function MePage() {
|
||||
const [otaVersion, setOtaVersion] = useState(null) // hotswap 当前激活的覆盖包版本
|
||||
const [progress, setProgress] = useState({ received: 0, total: 0 })
|
||||
const [error, setError] = useState('')
|
||||
// 判新模式: false(默认)=版本号比较, true=build sequence 比较; 值持久化在设备端
|
||||
const [devMode, setDevMode] = useState(() => getOtaDevMode())
|
||||
|
||||
// 进入页面时拉一次 hotswap 状态 (Tauri 内才需要, web 下 null 也无所谓)
|
||||
useEffect(() => {
|
||||
@@ -43,7 +47,7 @@ export default function MePage() {
|
||||
setError('')
|
||||
setOtaState('checking')
|
||||
try {
|
||||
const r = await checkOtaUpdate()
|
||||
const r = await checkOtaUpdate({ devMode })
|
||||
if (r.available) {
|
||||
setOtaInfo(r.info)
|
||||
setOtaState('available')
|
||||
@@ -130,6 +134,30 @@ export default function MePage() {
|
||||
<List.Item extra={otaVersion?.active ? 'v' + (otaVersion.version || '?') : '内置'}>
|
||||
运行资源
|
||||
</List.Item>
|
||||
{/* 判新口径开关: 默认关(版本号比较), 打开后按 build sequence 比较 */}
|
||||
<List.Item
|
||||
extra={
|
||||
<Switch
|
||||
checked={devMode}
|
||||
onChange={(v) => {
|
||||
setDevMode(v)
|
||||
setOtaDevMode(v)
|
||||
// 切换口径后旧检查结果失效, 重置避免误导
|
||||
setOtaState('idle')
|
||||
setOtaInfo(null)
|
||||
setError('')
|
||||
Toast.show({ content: v ? '开发版本: 按构建号判新' : '正式口径: 按版本号判新' })
|
||||
}}
|
||||
/>
|
||||
}
|
||||
description={
|
||||
devMode
|
||||
? '按 build sequence 判新:每次构建都算新版本,便于开发联调'
|
||||
: '按版本号判新:仅当服务端版本号更高时才提示更新'
|
||||
}
|
||||
>
|
||||
开发版本
|
||||
</List.Item>
|
||||
<List.Item>
|
||||
<div className="me-ota-actions">
|
||||
<Button
|
||||
@@ -209,4 +237,4 @@ export default function MePage() {
|
||||
<div className="me-version">v{APP_VERSION} · build {BUILD_ID}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,282 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import {
|
||||
Button, Dialog, DotLoading, Empty, Input, Popup, Selector, Stepper, Toast,
|
||||
} from 'antd-mobile'
|
||||
import RingProgress, { Celebrate } 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 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" 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) + '月'
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { Card, DotLoading, Empty } from 'antd-mobile'
|
||||
import { api } from '../../api/client.js'
|
||||
import useRefreshOnVisible from '../../hooks/useRefreshOnVisible.js'
|
||||
import { fmtDateTime } from '../../utils/format.js'
|
||||
|
||||
// 报警 (V0.2 从「首页」并入「宏观」Tab)
|
||||
// - 数据: World Bank 与妙想 MCP 跨源同 (指标, 周期) 数据差异超阈值, 按时间倒序
|
||||
// - 无限滚动: 触底自动加载下一页
|
||||
// - onCount: 把总数回传给父级 (宏观 Tab 上显示角标)
|
||||
export default function AlertsView({ onCount }) {
|
||||
const [items, setItems] = useState([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [done, setDone] = useState(false)
|
||||
const cursorRef = useRef(null)
|
||||
const sentinelRef = useRef(null)
|
||||
const inFlightRef = useRef(false)
|
||||
|
||||
const fetchPage = useCallback(async () => {
|
||||
if (inFlightRef.current || done) return
|
||||
inFlightRef.current = true
|
||||
setLoading(true)
|
||||
try {
|
||||
const url = '/alerts?limit=10' + (cursorRef.current ? '&cursor=' + cursorRef.current : '')
|
||||
const data = await api(url)
|
||||
setItems((prev) => prev.concat(data.items || []))
|
||||
onCount?.(data.total ?? (data.items?.length || 0))
|
||||
cursorRef.current = data.nextCursor
|
||||
if (!data.nextCursor) setDone(true)
|
||||
} catch (e) {
|
||||
console.error('alerts load error', e)
|
||||
setDone(true)
|
||||
} finally {
|
||||
inFlightRef.current = false
|
||||
setLoading(false)
|
||||
}
|
||||
}, [done, onCount])
|
||||
|
||||
useEffect(() => { fetchPage() }, [])
|
||||
// 后台回前台: 重置游标重拉第一页, 新数据到了再更新视图
|
||||
const reload = useCallback(async () => {
|
||||
if (inFlightRef.current) return
|
||||
inFlightRef.current = true
|
||||
setLoading(true)
|
||||
try {
|
||||
const data = await api('/alerts?limit=10')
|
||||
setItems(data.items || [])
|
||||
onCount?.(data.total ?? (data.items?.length || 0))
|
||||
cursorRef.current = data.nextCursor
|
||||
setDone(!data.nextCursor)
|
||||
} catch (e) {
|
||||
console.error('alerts reload error', e)
|
||||
setDone(true)
|
||||
} finally {
|
||||
inFlightRef.current = false
|
||||
setLoading(false)
|
||||
}
|
||||
}, [onCount])
|
||||
useRefreshOnVisible(reload)
|
||||
useEffect(() => {
|
||||
if (done || items.length === 0) return
|
||||
const el = sentinelRef.current
|
||||
if (!el) return
|
||||
const io = new IntersectionObserver((entries) => {
|
||||
if (entries[0].isIntersecting) fetchPage()
|
||||
}, { rootMargin: '200px' })
|
||||
io.observe(el)
|
||||
return () => io.disconnect()
|
||||
}, [items.length, done, fetchPage])
|
||||
|
||||
if (items.length === 0 && !loading) {
|
||||
return (
|
||||
<section className="home-section">
|
||||
<Empty description="暂无报警" />
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="home-section">
|
||||
<div className="section-list">
|
||||
{items.map((a) => (
|
||||
<Card key={a.id} className="alert-card">
|
||||
<div className="alert-title">
|
||||
<span className="alert-name">{a.indicatorName}</span>
|
||||
<span className="alert-period">{a.period}</span>
|
||||
</div>
|
||||
<div className="alert-diff">
|
||||
{a.sourceA} = {a.valueA} vs {a.sourceB} = {a.valueB}
|
||||
<span className="alert-ratio"> 差异 {(a.diffRatio * 100).toFixed(1)}%</span>
|
||||
</div>
|
||||
<div className="alert-time">{fmtDateTime(a.createdAt)}</div>
|
||||
</Card>
|
||||
))}
|
||||
<div ref={sentinelRef} className="scroll-sentinel">
|
||||
{loading ? <DotLoading /> : (done ? '— 已加载全部 —' : '')}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Card, DotLoading, Empty } from 'antd-mobile'
|
||||
import { api } from '../../api/client.js'
|
||||
import useRefreshOnVisible from '../../hooks/useRefreshOnVisible.js'
|
||||
import { fmtDate } from '../../utils/format.js'
|
||||
|
||||
// 新闻 (V0.2 从「首页」并入「宏观」Tab)
|
||||
// - 数据: mx_finance_search_news 每日 3 点拉取国内外经济政治新闻, 按时间倒序
|
||||
// - 无限滚动: 触底自动加载下一页
|
||||
// - 详情: /macro/news/:id (挂在宏观 Tab 下, 保持底部 Tab 高亮)
|
||||
export default function NewsView() {
|
||||
const [items, setItems] = useState([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [done, setDone] = useState(false)
|
||||
const cursorRef = useRef(null)
|
||||
const sentinelRef = useRef(null)
|
||||
const inFlightRef = useRef(false)
|
||||
const navigate = useNavigate()
|
||||
|
||||
const fetchPage = useCallback(async () => {
|
||||
if (inFlightRef.current || done) return
|
||||
inFlightRef.current = true
|
||||
setLoading(true)
|
||||
try {
|
||||
const url = '/news?limit=10' + (cursorRef.current ? '&cursor=' + encodeURIComponent(cursorRef.current) : '')
|
||||
const data = await api(url)
|
||||
setItems((prev) => prev.concat(data.items || []))
|
||||
cursorRef.current = data.nextCursor
|
||||
if (!data.nextCursor) setDone(true)
|
||||
} catch (e) {
|
||||
console.error('news load error', e)
|
||||
setDone(true)
|
||||
} finally {
|
||||
inFlightRef.current = false
|
||||
setLoading(false)
|
||||
}
|
||||
}, [done])
|
||||
|
||||
useEffect(() => { fetchPage() }, [])
|
||||
// 后台回前台: 重置游标重拉第一页, 新数据到了再更新视图
|
||||
const reload = useCallback(async () => {
|
||||
if (inFlightRef.current) return
|
||||
inFlightRef.current = true
|
||||
setLoading(true)
|
||||
try {
|
||||
const data = await api('/news?limit=10')
|
||||
setItems(data.items || [])
|
||||
cursorRef.current = data.nextCursor
|
||||
setDone(!data.nextCursor)
|
||||
} catch (e) {
|
||||
console.error('news reload error', e)
|
||||
setDone(true)
|
||||
} finally {
|
||||
inFlightRef.current = false
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
useRefreshOnVisible(reload)
|
||||
useEffect(() => {
|
||||
if (done || items.length === 0) return
|
||||
const el = sentinelRef.current
|
||||
if (!el) return
|
||||
const io = new IntersectionObserver((entries) => {
|
||||
if (entries[0].isIntersecting) fetchPage()
|
||||
}, { rootMargin: '200px' })
|
||||
io.observe(el)
|
||||
return () => io.disconnect()
|
||||
}, [items.length, done, fetchPage])
|
||||
|
||||
if (items.length === 0 && !loading) {
|
||||
return (
|
||||
<section className="home-section">
|
||||
<Empty description="暂无新闻" />
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="home-section">
|
||||
<div className="section-list">
|
||||
{items.map((n) => (
|
||||
<Card key={n.id} className="news-card" onClick={() => navigate('/macro/news/' + n.id)}>
|
||||
<div className="news-title">{n.title}</div>
|
||||
{n.summary && <div className="news-summary">{n.summary}</div>}
|
||||
<div className="news-meta">
|
||||
<span className="news-source">{n.source || '未知来源'}</span>
|
||||
<span className="news-time">{fmtDate(n.publishedAt)}</span>
|
||||
<span className="news-rank">#{n.impactRank}</span>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
<div ref={sentinelRef} className="scroll-sentinel">
|
||||
{loading ? <DotLoading /> : (done ? '— 已加载全部 —' : '')}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
+171
-6
@@ -210,12 +210,9 @@ input, textarea, select { font: inherit; }
|
||||
.me-list .adm-list-item { padding-left: 16px; padding-right: 16px; }
|
||||
.me-logout { color: var(--color-danger); }
|
||||
.me-version { text-align: center; font-size: 12px; color: var(--color-muted); padding: 18px 0 8px; }
|
||||
/* 首页模块: 报警 + 新闻 */
|
||||
.home-content { padding: 0 0 16px; flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; }
|
||||
.home-content .adm-tabs-content { --content-padding: 0; }
|
||||
.home-content .tab-label { display: inline-flex; align-items: center; gap: 4px; }
|
||||
.home-section { padding: 0 12px 16px; }
|
||||
.section-title { font-size: 16px; font-weight: 700; padding: 14px 4px 8px; color: var(--color-text); }
|
||||
/* 资讯模块: 报警 + 新闻 (V0.2 起从「首页」并入「宏观」Tab) */
|
||||
.tab-label { display: inline-flex; align-items: center; gap: 4px; }
|
||||
.home-section { padding: 12px 12px 16px; }
|
||||
.section-list { display: flex; flex-direction: column; gap: 8px; }
|
||||
.alert-card.adm-card { border-radius: 10px; margin: 0; }
|
||||
.alert-card .adm-card-body { padding: 10px 12px; }
|
||||
@@ -500,3 +497,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); }
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
// 通用格式化工具
|
||||
export function fmtDate(s) {
|
||||
if (!s) return ''
|
||||
const d = new Date(s)
|
||||
if (isNaN(d.getTime())) return s
|
||||
const pad = (n) => String(n).padStart(2, '0')
|
||||
return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate())
|
||||
}
|
||||
|
||||
export function fmtDateTime(s) {
|
||||
if (!s) return ''
|
||||
const d = new Date(s)
|
||||
if (isNaN(d.getTime())) return s
|
||||
const pad = (n) => String(n).padStart(2, '0')
|
||||
return (
|
||||
d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate()) +
|
||||
' ' + pad(d.getHours()) + ':' + pad(d.getMinutes())
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
// 版本号比较 (纯函数, 无浏览器依赖, 方便单测)
|
||||
//
|
||||
// 口径: 把版本号按"非数字"切成数字字段, 逐段比较数值大小, 缺位补 0
|
||||
// "0.2.0-ota" → [0, 2, 0]
|
||||
// "v1.10.3" → [1, 10, 3]
|
||||
// "0.2" 与 "0.2.0" 视为相等
|
||||
|
||||
export function parseVersionFields(v) {
|
||||
return String(v || '')
|
||||
.trim()
|
||||
.split(/[^0-9]+/)
|
||||
.filter((s) => s !== '')
|
||||
.map((s) => Number(s))
|
||||
}
|
||||
|
||||
// a > b 返回 1, a < b 返回 -1, 相等返回 0
|
||||
export function compareVersion(a, b) {
|
||||
const fa = parseVersionFields(a)
|
||||
const fb = parseVersionFields(b)
|
||||
const n = Math.max(fa.length, fb.length)
|
||||
for (let i = 0; i < n; i++) {
|
||||
const x = fa[i] || 0
|
||||
const y = fb[i] || 0
|
||||
if (x !== y) return x > y ? 1 : -1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
Reference in New Issue
Block a user