32 Commits
Author SHA1 Message Date
jif2.zhang d6ca7573f3 fix(V0.2): 三轮反馈 5 项 —— 回滚键盘方案 / 列表卡片拖动 / 看板防误触 / 横滑整屏 / 详情页恢复
Docker Build and Push / build-image (push) Successful in 4m9s
1. 回滚键盘遮挡统一方案 (真机测试不通过, 需求搁置): 恢复 TaskDetail/
   UserPicker/CopyTaskPopup/ProjectManage 与 useKeyboardInset 到 9fea1a5^,
   删除 useKeyboardInsetWatcher / .kb-safe / interactive-widget=resizes-content
2. 任务详情页样式随 1 一并恢复提交前状态 (含底部拷贝/删除按钮)
3. 列表视图换列改为卡片拖动: 新 useRowDrag (长按 400ms 行抬起, 左右
   80px/列步进, 行尾徽标显示目标列, 松手落列), 移除长按 ActionSheet
   —— 旧长按与复选框/行点击互相误触, 抬手合成 click 落在控件上触发状态切换
4. 看板拖动防误触: 任务区禁长按文字选择与系统菜单 (输入框除外);
   pointercancel 只放弃不提交; 拖动结束 350ms 内抑制 click 误开详情;
   状态点/⋯/子任务行 pointerdown 不冒泡, 不发起拖动
5. 看板横滑扩展到整屏: .page-transition 补 height:100% 打通
   app-main → page-with-navbar 百分比链, board-wrap 撑满剩余高度

已验证: kb-safe/Watcher/onLongPress 残留引用清零, 逐文件 diff 审阅通过;
本机无 Node 运行时, 未跑 vite build
2026-10-24 23:24:31 +08:00
jif2.zhang 9fea1a5a3a fix(V0.2): 二轮评审 6 项 —— BUILD_ID 格式 / 菜单与详情精简 / 拖动重做 / 键盘统一方案
Docker Build and Push / build-image (push) Successful in 4m5s
1. BUILD_ID 改为 yyyyMMddHHmmss (去掉中间点), 仍 UTC+8
2. 任务 ⋯ 菜单去掉顶部任务名标题
3. 任务详情去掉底部「拷贝到其他项目」「删除任务」按钮 (⋯ 菜单已有)
4+6. 键盘遮挡统一方案 (重点设计 §7.2):
   - index.html 加 interactive-widget=resizes-content
   - App 级 useKeyboardInsetWatcher 写 --kb-inset (visualViewport+window 双源)
   - 底部弹层统一 .kb-safe 类 (安全区+20px+键盘插入量, dvh 收缩+内部滚动)
   - TaskDetail/UserPicker/CopyTaskPopup/信息块弹层全部迁移, 删除 per-component hook
   - 信息块 textarea 补 focus scrollIntoView 兜底
5. 拖动方案重做 (重点设计 §7.1):
   - 新 useColumnDrag: Pointer Events + setPointerCapture, 长按400ms原位抬起,
     elementFromPoint 高亮目标列, 边缘 rAF 自动横滚; 移除手指跟随浮层(任务名)
   - 列表行长按 500ms → 「移动到列」ActionSheet (不做自由拖动)
   - 移除「拖到卡片转子任务」误触路径, 统一走详情页层级区
   - 删除旧 useLongPressDrag

已验证: 99 文件语法 0 错误、导入 0 缺失
2026-10-24 22:15:52 +08:00
jif2.zhang 506db0795b feat(V0.2): 任务操作菜单 / 状态指示 / 移动与转子任务 / 移除扩展信息
Docker Build and Push / build-image (push) Successful in 4m13s
1. 任务 ⋯ 菜单 (列表行与看板卡片): 编辑 / 拷贝到其他项目 / 删除
   (antd-mobile ActionSheet, 带任务名标题; 拷贝抽出为 CopyTaskPopup 复用)
2. 点击任务非按钮区域 = 打开编辑页 (原本即如此, ⋯ 不再误触为编辑)
3. 修复状态选择器堆成三行: 此前 .seg 只有 .pv-head 作用域样式,
   详情弹层裸用导致纵向堆叠; 新增通用 .seg 分段样式, 当前项高亮点按即切
4. 移除任务的扩展信息 UI (T-TK-04~07 标注已取消, 决策 35):
   ExtrasCard 组件删除, DB extras 字段保留不再使用;
   项目相关长文本由项目信息块 (T-PJ-13) 承载
5. 任务移动入口: 详情页新增「看板列」ActionSheet 换列(联动裁决不变)
   与「层级」区(转为子任务=选父任务 / 转为顶层), 覆盖无法拖拽的场景
6. 列表页移除「+ 子任务」入口: 添加子任务只在详情页, 或经转子任务

已验证: 99 文件语法 0 错误、导入 0 缺失、文档 149 条/决策 35 连续
2026-10-24 21:51:26 +08:00
jif2.zhang b71ceca3f2 fix(V0.2): BUILD_ID 时区 / 新建弹框残留 / 项目信息块
Docker Build and Push / build-image (push) Successful in 4m19s
1. BUILD_ID 改用 UTC+8 生成: CI runner 是 UTC 时区, 原来取本地时间会错位;
   统一偏移 +8h 后按 UTC 读取 (vite.config.js makeBuildId)

2. 修复「我的项目」页反复弹新建框: createSignal 一次性消费,
   ProjectList 处理后回调清零 (此前 TaskPage 状态在 /task 与 /task/list
   间保留, 信号残留导致每次进入都触发)

3. 新增项目信息块 (T-PJ-13):
   - Project.notes Json ([{title,content}], <=50 块 / 标题<=50 字 / 内容<=20000 字)
   - 迁移 20261024130000: 加列(NULL)->回填'[]'->收紧 NOT NULL, 存量行安全
   - GET 详情返回 notes; PUT 接受 notes 整体替换
   - 项目管理新增「信息块」分区: 列表预览 + 底部弹层独立编辑(标题+多行
     textarea+删除), 键盘弹出复用 useKeyboardInset 抬升
   - 文档: 需求 T-PJ-13 (149 条) + 功能设计对应行

已验证: 109 文件语法 0 错误、文档 149/149 覆盖
2026-10-24 21:37:05 +08:00
jif2.zhang 1fd7522fa6 fix(V0.2): 任务模块 4 个 bug —— 覆盖层安全区/返回键/项目列表排序/键盘遮挡
Docker Build and Push / build-image (push) Successful in 4m17s
1. 项目管理等 cs-page 覆盖层与系统状态栏重合:
   .cs-page 补 padding-top: var(--safe-top) (复用 main.jsx 实测校准的变量)

2. 项目管理按返回键跑到上一个 Tab:
   新增 useOverlayBack —— 覆盖层打开时 pushState 哨兵, 返回键先关覆盖层
   应用于 项目管理(内层视图先回主区) 与 任务详情弹层

3. 更多项目页:
   - 头部补「+ 新建」入口
   - 项目列表支持长按拖动排序(纵向), 顺序存设备端, 与首屏最近项目共用 recentOrder
   - 顺带修复首屏 RecentProjects 拖动排序的闭包缺陷(监听器读到旧下标, 首次移动
     会 splice(-1)); 两处均改为 ref 取当前下标

4. 任务详情输入子任务被键盘遮挡:
   新增 useKeyboardInset (visualViewport 差值), 键盘弹出时抬升弹层内容并收缩
   maxHeight; 输入框聚焦后再 scrollIntoView 到可视区中部 (双保险)

已验证: 99 文件语法 0 错误、相对导入 0 缺失
2026-10-24 21:09:19 +08:00
jif2.zhang 0cdbfb434f feat(V0.2): 任务模块界面调整 —— 首屏/项目管理/底部安全距离
Docker Build and Push / build-image (push) Successful in 4m8s
按 2026-10-24 评审的 5 条调整实现 (需求 T-VW-08~10 / T-PJ-11/12 / T-UI-11):

首屏 (T-VW-08/09):
- /task 改为「上=最近项目条(3 个+更多入口), 下=当前项目任务管理」
- 完整项目列表迁至 /task/list, 从「更多项目」进入; /task/:pid 保留供深链

最近项目排序 (T-VW-10):
- 卡片长按 300ms 拖动换位, 结果存设备端 prefs.recentOrder
- 顺序 = 手动排序 > 最近访问(recentIds) > 列表序, 归档项目不展示

项目管理 (T-PJ-11/12):
- 新增 ProjectManage: 成员 / 看板列 / 项目信息 三分区统一入口
- 项目信息含 改名 / 归档切换 / 删除(个人项目隐藏) / 退出(仅创建人)
- 项目页头部只留「项目管理 + 新增」, 移除散置的成员/列设置按钮

底部安全距离 (T-UI-11):
- 任务详情 / 人员选择 / 拷贝弹层 padding-bottom = 安全区 + 20px
- 小屏下弹层 maxHeight + 内部滚动, 底部按钮完整可见

已验证: 97 文件语法 0 错误、相对导入 0 缺失
2026-10-24 20:32:44 +08:00
jif2.zhang 6153b9db99 fix(V0.2): 清除 schema.prisma 中段 BOM, 修复 CI prisma generate 失败
Docker Build and Push / build-image (push) Successful in 4m24s
追加任务模块模型时临时文件带入 U+FEFF, Prisma 解析在 383 行报 Validation Error (run 200)
2026-10-24 17:43:30 +08:00
jif2.zhang 6c9cd3fd99 feat(V0.2): 任务模块 —— 项目 / 列表与看板 / 列-状态绑定 / 一级子任务
Docker Build and Push / build-image (push) Failing after 3m51s
需求 142 条 (T-*), 设计见 docs/V0.2/任务模块/

数据层:
- 新增 6 张表: Project / ProjectMember / BoardColumn / Task / TaskMember / TaskTag
- 看板列是实体(可增删改排序), Task.columnId 外键 RESTRICT 防误删列丢任务
- Task.status(todo/doing/done) 与 columnId 双落库, 二者正交

领域层 server/task-domain/ (唯一口径, 前后端不重复实现):
- resolveStatus: 列决定状态(只看目标列, 无拖出回退); 状态与列不匹配时按列序取第一个绑定该状态的列自动归位, 无匹配列则不移动; 子任务完全豁免
- retention: 逐列 6 档保留窗口(永不显示/当天/3天/本周/本月/永久显示), 以 doneAt 判定
- copyTask: 跨项目深拷贝, 人员按目标项目成员过滤并回报丢弃名单

接口: /api/projects, /api/columns, /api/tasks (均校验项目成员)

前端:
- 底部 Tab 插入「任务」, 位于「打卡」之后; 默认首页仍为打卡
- 列表视图(子任务缩进+折叠) / 看板视图(长按拖拽换列、拖到卡片降为子任务)
- 列设置(列管理+状态绑定+保留选项) / 任务详情(扩展信息卡片默认收起) / 成员管理
- 视图记忆与上次项目存设备端

已验证: 95 文件语法 0 错误、相对导入 0 缺失、领域逻辑用例全部通过
2026-10-24 11:21:16 +08:00
jif2.zhang 42acf72f1e 合并 dev-0.2 到 master: 修复打卡按钮点不动
Docker Build and Push / build-image (push) Successful in 4m9s
2026-10-24 09:53:14 +08:00
jif2.zhang e57b7902be 修复打卡页所有打卡/再打一次按钮点不动
回归原因: 合并视图引入 doCheck 时加了 `if (!item.canCheck || isFuture) return`.
但 canCheck 字段**只有 /day 接口返回**, 本视图数据取自 /habits, 该接口
不返回 canCheck → item.canCheck === undefined → !undefined === true
→ 每个按钮点击都在第一行直接 return, 表现为"点不动".

修复: doCheck 只按 isFuture 拦截, 与后端 POST /habits/:id/check 的校验
口径一致 (后端只拒绝未来日期与超过补打窗口的日期).

影响范围: 打卡按钮、再打一次按钮、环形点击 (三处共用 doCheck).
2026-10-24 09:53:08 +08:00
jif2.zhang 6a48b7311b 合并 dev-0.2 到 master: 打卡页新建按钮调整
Docker Build and Push / build-image (push) Successful in 4m4s
2026-10-24 08:40:15 +08:00
jif2.zhang 7e229ecc84 打卡页: 「点环即打卡」提示换成新建按钮, 去掉底部歪掉的按钮
问题:
- 当日栏右侧的「点环即打卡」是纯文字提示, 看着像可点但实际无交互, 容易误导
- 分组列表底部另有一个「+ 新建打卡项」按钮, 带内联 margin,
  与卡片左右边距不一致, 视觉上是歪的

改动:
- 当日栏右侧改为「+ 新建」按钮 (antd-mobile Button, mini) 右对齐,
  复用原有 openCreate, 一个入口完成新建
- 删除底部「+ 新建打卡项」按钮及其 .mv-add-wrap 样式
- .mv-daybar 移除废弃的 .sum 样式, 新增 .mv-new-btn (margin-left:auto 右对齐)

空态「创建第一个打卡项」保留 (无任何打卡项时的引导)。
2026-10-24 08:40:09 +08:00
jif2.zhang 3250b94bc6 合并 dev-0.2 到 master: 打卡与日历合并视图 (按天/周/月分组)
Docker Build and Push / build-image (push) Successful in 4m1s
2026-10-24 08:30:53 +08:00
jif2.zhang 330e6585d9 feat(V0.2): 打卡与日历合并为单视图, 打卡内容按 每天/每周/每月 分组
实现设计文档 §11.5「日历与打卡合并」待确认项 (界面设计.html 2.12 ②).

结构:
- CheckinPage 由 3 个 tab 收敛为 2 个: 打卡(含日历) / 清单
- 新增 MergedView.jsx: 上为月历, 下为当日打卡项
  (原 CalendarView + HabitView 的展示/交互合并到一个组件)

按规格落地:
- 月历: 月份切换 + 回到今天; 去掉「昨天/今天/明天」分段; 日期格 38→31px
- 分组: 每天 → 每周 → 每月, 组标题左侧 3px 主色竖条 + 右侧项数; 去掉「每年」分组
  (若存在历史 year 项则归入"其它周期"分组, 避免数据凭空消失)
- 卡片紧凑化: 内边距 12→8px, 环形 58→42px (环宽 6→5), 间距 10→7px
- 操作: 撤销 / 编辑 / 归档 由卡片底部三个文字链接收进「⋯」底部面板
- 联动: 点日期即切换当日数据; 未来日期只读, 按钮灰显「计划」
- 组内仍按「未完成在前、已完成在后」排序 (F-CK-16)

样式: globals.css 新增 .mv-* 段 (月历/分组/紧凑卡片/操作面板), 数值与设计稿一致.

服务端无需改动: habits 接口已返回 period/periodLabel/expectedPerDay/canCheck 等字段.
2026-10-24 08:30:48 +08:00
jif2.zhang 99cedce4ed 修复「我」页顶部安全区与蓝色渐变头部割裂
现象: 顶部安全区修好后, 其他页面正常, 但「我」页顶部多出一条白色/色块,
把原本从屏幕顶端开始的蓝色渐变隔断.

原因: .app-safe-top 是独立占位元素, 固定使用页面底色.
「我」页头部 me-profile 是通栏蓝色渐变 (无 PageHeader), 安全区夹在中间
就形成一条异色带, 视觉上把蓝色切成两段.

修复:
- MePage 挂载时给 body 加 me-safe-top 标记, 卸载时移除
- 该标记下把 .app-safe-top 高度归零, 改由 .me-profile 的
  padding-top: calc(24px + var(--safe-top)) 吸收状态栏高度
- 这样蓝色渐变直接从屏幕最顶端开始, 是一整块连续色域, 无接缝,
  同时内容仍正确避开电量/信号栏

其他页面不受影响 (无该 body 标记, 安全区照常占位).
2026-10-24 08:15:09 +08:00
jif2.zhang a150706cc4 修复「我」页顶部安全区与蓝色渐变头部割裂
Docker Build and Push / build-image (push) Successful in 4m3s
现象: 顶部安全区修好后, 其他页面正常, 但「我」页顶部多出一条白色/色块,
把原本从屏幕顶端开始的蓝色渐变隔断.

原因: .app-safe-top 是独立占位元素, 固定使用页面底色.
「我」页头部 me-profile 是通栏蓝色渐变 (无 PageHeader), 安全区夹在中间
就形成一条异色带, 视觉上把蓝色切成两段.

修复:
- MePage 挂载时给 body 加 me-safe-top 标记, 卸载时移除
- 该标记下把 .app-safe-top 高度归零, 改由 .me-profile 的
  padding-top: calc(24px + var(--safe-top)) 吸收状态栏高度
- 这样蓝色渐变直接从屏幕最顶端开始, 是一整块连续色域, 无接缝,
  同时内容仍正确避开电量/信号栏

其他页面不受影响 (无该 body 标记, 安全区照常占位).
2026-10-24 08:15:05 +08:00
jif2.zhang a915a08f4d 修复 OTA 后顶部内容顶到状态栏 (未让出电量/信号栏)
现象: 直装 APK 时头部正常让开状态栏; 经 OTA 升级到 V0.2 前端后,
页面从屏幕最顶部开始, 与电量/信号栏重叠.

根因: 顶部让位一直只依赖 antd-mobile 的 <SafeArea position="top" />,
它只是一个空 div, 高度完全来自 env(safe-area-inset-top). 而 Android
WebView (Tauri) 下该变量常返回 0.
旧版每页顶部都有 45px 的 antd-mobile <NavBar>, 视觉上兜住了状态栏,
掩盖了 inset 失效; V0.2 把 NavBar 换成 23px 的 PageHeader
(「我」页甚至无头部), 兜底消失后问题暴露. 与 OTA 机制本身无关.

修复:
- MobileShell: <SafeArea> 换成 .app-safe-top, 高度由 --safe-top 控制
- main.jsx: 新增 calibrateSafeTop(), 用临时元素测 env(safe-area-inset-top),
  并与 screen.height - visualViewport.height 交叉校验, 取较大值写入
  --safe-top; 两者都不可用时保底 24px; 监听旋转/视口变化重算
- globals.css: --safe-top 保留 env() 表达式, 新增 .app-safe-top 样式

这样无论 WebView 是否上报 inset, 顶部都能正确让位.
2026-10-24 08:05:19 +08:00
jif2.zhang 2ea0da758f 修复 OTA 后顶部内容顶到状态栏 (未让出电量/信号栏)
Docker Build and Push / build-image (push) Successful in 4m2s
现象: 直装 APK 时头部正常让开状态栏; 经 OTA 升级到 V0.2 前端后,
页面从屏幕最顶部开始, 与电量/信号栏重叠.

根因: 顶部让位一直只依赖 antd-mobile 的 <SafeArea position="top" />,
它只是一个空 div, 高度完全来自 env(safe-area-inset-top). 而 Android
WebView (Tauri) 下该变量常返回 0.
旧版每页顶部都有 45px 的 antd-mobile <NavBar>, 视觉上兜住了状态栏,
掩盖了 inset 失效; V0.2 把 NavBar 换成 23px 的 PageHeader
(「我」页甚至无头部), 兜底消失后问题暴露. 与 OTA 机制本身无关.

修复:
- MobileShell: <SafeArea> 换成 .app-safe-top, 高度由 --safe-top 控制
- main.jsx: 新增 calibrateSafeTop(), 用临时元素测 env(safe-area-inset-top),
  并与 screen.height - visualViewport.height 交叉校验, 取较大值写入
  --safe-top; 两者都不可用时保底 24px; 监听旋转/视口变化重算
- globals.css: --safe-top 保留 env() 表达式, 新增 .app-safe-top 样式

这样无论 WebView 是否上报 inset, 顶部都能正确让位.
2026-10-24 08:05:15 +08:00
jif2.zhang 2f55865092 合并 dev-0.2 到 master: V0.2 功能 + 米家修复 + 分支策略调整
Docker Build and Push / build-image (push) Successful in 8m0s
2026-10-24 07:36:58 +08:00
jif2.zhang 5e6ea552d1 调整分支策略: 仅 master 部署, 取消 dev-* 自动发布
分支规则 (写入 readme):
- master   主分支, 唯一部署出口, push 即发布生产
- dev-*    开发集成分支, 只跑 CI 校验, 不部署; 上线先合到 master
- tmp-*    临时分支, 针对单个问题, 合到 dev-* 后删除
- android-* 仅触发 APK 外壳构建, 构建前须与 master 同步

CI 变更:
- .gitea/workflows/docker-build.yml 触发分支收窄为 master
  此前 dev-* 与 master 共用同一套生产环境, 推送开发分支即直接发布,
  容易把半成品带上线, 现取消该触发
- 同步修正工作流内相关注释 (迁移时机, 版本校验范围)

文档变更:
- readme 新增「分支规则」章节: 职责表/流程图/CI 触发范围/变更说明
- readme 原「分支与 CI/CD」章节与新规则冲突, 已按新策略重写
  (dev-* 不再触发部署; 数据库迁移只在 master 发布时应用)
- 明确团队约定的 main 主分支在本仓库落地为 master
2026-10-24 07:36:53 +08:00
jif2.zhang a66ddb457a 修复扫码弹窗一闪而过
现象: 米家授权过期后, 扫码弹窗从底部弹出但瞬间消失, 看不到二维码.

三处缺陷:
1. 服务端 startQrLogin() 在已有流程运行时提前 return, 未清 done.
   上一轮成功残留的 done=true 会让前端第一次轮询就拿到
   {running:false, done:true}, 误判为扫码成功立即关窗.
   改为复用流程时显式返回 done:false.
2. 前端 AuthPopup 的成功/失败判定没有幂等保护, 且 onClose/onOk 是
   父组件每次渲染新建的函数. 父级轮询触发重渲染后, 回调身份变化
   会波及弹窗关闭路径. 改用 ref 持有最新回调 + settledRef 保证
   一轮流程只结算一次.
3. 弹窗遮罩绑定 onMaskClick=cancel, 手机端极易误触; 关闭遮罩点击,
   只允许显式点「取消」关闭.

注意: 前端需重新构建发布, 服务端需重启后生效.
2026-10-24 07:27:03 +08:00
jif2.zhang 3de002aae8 修复米家会话失效后不弹登录二维码
根因一: 过期检测形同虚设
- @zythum02/mijia-api 的 refreshToken() 在本地凭证字段齐全时直接返回成功,
  根本不发请求, 因此 checkAuth() 永远判定 ok, 过期后既不出横幅也不出扫码入口
- checkAuth() 改为真打一次 getHomesList() 验证 token; 成功缓存 5 分钟,
  失败只缓存 30 秒(便于网络恢复后自愈); 增加并发探测合并; 状态未变不写库
- getStatus() 以实时探测结果为准, DB 里的 status 仅在探测为 unknown 时兜底,
  避免残留的 ok 遮蔽已失效状态

根因二: 二维码图片 401 导致弹窗空白
- /api/mihome/reauthorize/qr-image 在 authRequired 之后, 而 <img src> 无法携带
  Authorization 头, 图片请求被拒, 用户看到的是空白/裂图
- 新增 fetchMihomeQrImage(): 带 JWT fetch 后转 blob URL 给 <img>, 并在卸载/换码时释放

其他修复
- EnvView 的 load 依赖 [status] 且成功回调又 setStatus, 造成 /status 无限重复请求;
  改为空依赖 + 函数式 setStatus; 首屏与重试走 ?force=1 强查
- 会话失效(非网络类错误)时自动弹出扫码窗, 每次挂载仅弹一次
- 扫码状态机新增 done 标记, 只有真正登录成功才算成功, 避免取消后误判
- 二维码 120s 超时自动续码(最多 2 次), 慢扫不再直接失败
- 取消后不再丢弃可能已成功的扫码结果
- scripts/mihome-login.mjs 适配 done 语义
- 同步更新设计文档 §5.2 / §5.4 / §6

注意: 前端需重新构建发布后生效。
2026-10-24 07:26:59 +08:00
jif2.zhang d1f36b3b8f 修复扫码弹窗一闪而过
Docker Build and Push / build-image (push) Successful in 4m5s
现象: 米家授权过期后, 扫码弹窗从底部弹出但瞬间消失, 看不到二维码.

三处缺陷:
1. 服务端 startQrLogin() 在已有流程运行时提前 return, 未清 done.
   上一轮成功残留的 done=true 会让前端第一次轮询就拿到
   {running:false, done:true}, 误判为扫码成功立即关窗.
   改为复用流程时显式返回 done:false.
2. 前端 AuthPopup 的成功/失败判定没有幂等保护, 且 onClose/onOk 是
   父组件每次渲染新建的函数. 父级轮询触发重渲染后, 回调身份变化
   会波及弹窗关闭路径. 改用 ref 持有最新回调 + settledRef 保证
   一轮流程只结算一次.
3. 弹窗遮罩绑定 onMaskClick=cancel, 手机端极易误触; 关闭遮罩点击,
   只允许显式点「取消」关闭.

注意: 前端需重新构建发布, 服务端需重启后生效.
2026-10-24 06:30:00 +08:00
jif2.zhang 95b9985e8b 修复米家会话失效后不弹登录二维码
Docker Build and Push / build-image (push) Successful in 5m46s
根因一: 过期检测形同虚设
- @zythum02/mijia-api 的 refreshToken() 在本地凭证字段齐全时直接返回成功,
  根本不发请求, 因此 checkAuth() 永远判定 ok, 过期后既不出横幅也不出扫码入口
- checkAuth() 改为真打一次 getHomesList() 验证 token; 成功缓存 5 分钟,
  失败只缓存 30 秒(便于网络恢复后自愈); 增加并发探测合并; 状态未变不写库
- getStatus() 以实时探测结果为准, DB 里的 status 仅在探测为 unknown 时兜底,
  避免残留的 ok 遮蔽已失效状态

根因二: 二维码图片 401 导致弹窗空白
- /api/mihome/reauthorize/qr-image 在 authRequired 之后, 而 <img src> 无法携带
  Authorization 头, 图片请求被拒, 用户看到的是空白/裂图
- 新增 fetchMihomeQrImage(): 带 JWT fetch 后转 blob URL 给 <img>, 并在卸载/换码时释放

其他修复
- EnvView 的 load 依赖 [status] 且成功回调又 setStatus, 造成 /status 无限重复请求;
  改为空依赖 + 函数式 setStatus; 首屏与重试走 ?force=1 强查
- 会话失效(非网络类错误)时自动弹出扫码窗, 每次挂载仅弹一次
- 扫码状态机新增 done 标记, 只有真正登录成功才算成功, 避免取消后误判
- 二维码 120s 超时自动续码(最多 2 次), 慢扫不再直接失败
- 取消后不再丢弃可能已成功的扫码结果
- scripts/mihome-login.mjs 适配 done 语义
- 同步更新设计文档 §5.2 / §5.4 / §6

注意: 前端需重新构建发布后生效。
2026-10-23 22:21:08 +08:00
cheney 072f304cc2 docs(V0.2): 界面设计同步 2026-10-23 调整 (含日历+打卡合并草案) + 设计文档 §11
Docker Build and Push / build-image (push) Successful in 4m14s
界面设计.html
- 新增 2.12「V0.2.1 界面调整」章节: 头部统一 / 日历·打卡合并 / 模板分类 / 宏观 tab 溢出
- 已实现部分 (①③④) 与待确认草案 (②) 分别标注
- 2.9 宏观截图同步新 tab 顺序与右侧透支提示; 2.3 / 2.7 清单补「模板」分段
- 全部原型头部改用 23px 紧凑头部, 「我」页去掉头部条

设计文档.md
- 新增 §11 界面调整: 变更清单、头部统一对照表、模板接口与排序口径、宏观 tab 顺序与溢出实现、
  待确认的日历+打卡合并方案 (含确认后需改动的文件与样式段)
2026-10-23 16:41:04 +08:00
cheney c765755906 feat(UI): 四 Tab 头部统一 + 清单模板分类 + 宏观 tab 顺序与溢出提示
头部统一
- 新增 PageHeader 组件: 高度 23px (原 NavBar 45px 的一半), 标题 12px 主色加粗, 背景取页面底色
- 打卡 / 宏观 / 监控 三页各自引用, 互不影响; 宏观与监控补齐「宏观」「监控」标题
- 「我」页移除 NavBar 空白条, 蓝色头像区直接贴顶 (不引用 PageHeader, 与其余三页解耦)
- 清理失效样式 .me-navbar

清单模板分类
- 分段由 2 项扩为 3 项: 进行中 / 已归档 / 模板; 右上角按钮随分段切换
- 模板独立增删改查: 新增 GET /templates/:id、PUT /templates/:id; 预置模板拒绝改删
- 模板按名称字母顺序排序 (Intl.Collator zh-Hans-CN, 中文按拼音/数字按数值), 前后端同口径
- 新建清单去掉场景选择器, 仅保留名称; scene 改为沿用模板自带场景
- 模板改为从右侧滑出 78vw 面板选择, 首项「不使用模板」

宏观 tab
- 顺序改为 报警 → 新闻 → 油价 → 经济数据 → 策略研究 → 经济文章, 默认选中新闻
- /macro 由路由直接重定向到 /macro/news, activeFromPath 兜底同样返回 news
- SwipeTabs 增加右侧溢出提示: 渐变遮罩 + 主色圆底箭头, 可点击向右翻页, 滚到末尾自动消失
- 切换 tab 时自动把激活项滚到可视区中央
2026-10-23 16:40:32 +08:00
cheney 5f41cb05b7 chore(V0.2): 统一 APK 与 OTA 版本规则 —— 同一版本号, APK 裸写 / OTA 加 -ota
Docker Build and Push / build-image (push) Successful in 10m16s
- readme: 版本管理章节重写, 明确 APK=0.2.0 无后缀, OTA=0.2.0-ota 多一个标记
  并补充 APK versionName/versionCode 的生成链路 (tauri.properties, major*1e6+minor*1e3+patch)
- scripts/ci/check-version.mjs: 新增四处一致性校验
  (package.json / tauri.conf.json / Cargo.toml / Cargo.lock), 并禁止 version 带 -ota
- android-build.yml: 构建前也执行版本校验 (tauri.conf.json 的 version 就是 APK versionName)
- 需求文档 F-OT-01a、设计文档 §10.2 同步该规则
2026-10-22 17:34:22 +08:00
cheney 4a04720a5e feat(V0.2): OTA 版本与判新口径 + Tab 结构调整
1) 版本对齐分支
- package.json / tauri.conf.json / Cargo.toml / Cargo.lock: 0.1.0 -> 0.2.0, OTA 包版本变为 0.2.0-ota
- 新增 scripts/ci/check-version.mjs: 分支 dev-x.y 与 package.json 版本不一致时 CI 直接失败
- docker-build.yml / ota-build.yml: min_binary_version 与前端版本解耦, 固定 0.1.0
  (现有 APK 原生层仍是 0.1.0, 抬高会导致旧 APK 收不到更新)

2) 我的页 OTA「开发版本」开关 (默认关, 持久化 localStorage.iboard_ota_dev_mode)
- 关: 按版本号逐字段比较 (src/utils/version.js), 服务端 version 更高才算新
- 开: 按 build sequence 判新 (hotswap checkUpdate), 每次构建都能推到设备
- Web 端: 开发口径退化为版本不同即算新; 生产口径严格比较大小

3) 打卡 Tab 移到最左并默认选中, 移除特效开关
- BottomNav: 打卡/宏观/监控/我; 删除 home 图标
- App: / 与 404 均重定向到 /checkin
- RingProgress/Celebrate: 删除 effectsEnabled/setEffectsEnabled, 特效恒开
- HabitView: 去掉头部「特效开/关」按钮

4) 取消首页 Tab, 报警/新闻并入宏观
- 新增 pages/macro/AlertsView.jsx、NewsView.jsx; 删除 HomePage.jsx
- MacroPage: 6 个子 Tab (油价/经济数据/策略研究/经济文章/报警/新闻), 报警角标保留
- 新闻详情 /news/:id -> /macro/news/:id (宏观 Tab 保持高亮)

5) 文档: 需求文档补 F-OT 需求点与已决策项; 设计文档新增 §10 变更记录与判新设计;
   界面设计.html 11 屏 (Tab 4 项/去掉特效开关/宏观合并/我的 OTA 开关 关+开 两态)
2026-10-22 17:17:29 +08:00
cheney e1e0880ce6 ci: dev-* 分支纳入 docker-build 触发范围 (构建/部署/数据库自动升级)
Docker Build and Push / build-image (push) Successful in 6m53s
- docker-build.yml: push branches 增加 'dev-*', 与 main/master 同环境部署
- Apply DB migrations 步骤加 migrate status 输出并强调在 Remove old container 之前执行
- readme: 新增「分支与 CI/CD」章节, 说明分支触发矩阵与数据库升级约定
2026-10-22 15:20:28 +08:00
cheney 9b4705870d docs(V0.2): 新增设计文档 / 数据库设计文档 / 界面设计静态原型
- 设计文档.md: 架构与分层、目录结构、周期口径与边界规则、前端组件与交互、
  后端接口清单、关键流程时序、非功能设计、测试验证、风险与演进
- 数据库设计文档.md: ER、5 张表逐字段、索引与外键级联、关键设计决策
  (date 用 VARCHAR(10) 的原因 / 不冗余计数 / seq 幂等 / 归档语义 / 模板 JSON)、
  容量估算、典型 SQL、迁移与回滚、V0.3 预留
- 界面设计.html: 420x820 静态高保真原型 8 屏 (打卡/日历/清单/新建弹窗/
  完成特效/明天计划视图/清单完成/空态) + 设计规范、组件状态表、动效与适配约定
2026-10-22 14:12:24 +08:00
cheney 004f8c262d feat(V0.2): 打卡 / 日历 / 清单模块
- 数据: Habit/CheckIn/CheckList/CheckListItem/CheckListTemplate + 迁移 + 重新生成 client
- 后端: server/checkin/period.js 周期计算(日/周/月/年, 周一起始, Asia/Shanghai)
        /api/checkin/* 打卡项CRUD/打卡/撤销/补打/某日详情/月历
        /api/checklists/* 清单与检查项CRUD/勾选/全选/复制/模板(系统预置出差·回老家)
- 前端: 底部新增「打卡」Tab -> /checkin, 页内三视图
        环形进度 RingProgress + 完成特效(粒子/光晕/触感, 可关闭)
        打卡视图: 待办列表/乐观更新/撤销/新建编辑
        日历视图: 月历网格 + 今天·昨天·明天 + 日期标记/完成率
        清单视图: 场景清单/勾选进度条/模板生成/另存模板/归档
- 文档: docs/V0.2/需求文档.md 补充实现约定(附录B)与接口清单
2026-10-22 11:37:16 +08:00
cheney 54a3224f00 docs(V0.2): 新增打卡/日历/清单模块需求文档 (总体描述 + 分类编号需求点 + 里程碑 + 数据实体草案) 2026-10-22 10:03:09 +08:00
101 changed files with 23284 additions and 518 deletions
+5
View File
@@ -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:
+36 -6
View File
@@ -1,12 +1,21 @@
name: Docker Build and Push
# 仅 main 分支 push 触发, 其他分支不触发
# 每次主分支提交: 构建前端 + 生成 OTA 升级包 (签名) → 打进 Docker 镜像 → 部署
# 仅主分支 (master) push 触发部署; 其他分支一律不触发 (见 readme「分支规则」)
# 每次提交: 构建前端 + 生成 OTA 升级包 (签名) → 打进 Docker 镜像 → 自动升级数据库 → 部署
# OTA 随镜像发布: 容器内 OTA_PACKAGE_DIR=/app/ota-package 指向打包的 ota-package/
#
# 分支策略 (2026-10 调整):
# - master = 主分支, 唯一部署出口, push 即发布生产 (容器 iboard, 宿主 3001)
# - dev-* = 开发集成分支, 只做 CI 校验 (构建/版本一致性), **不再自动部署**;
# 要上线必须先合并到 master 再推 master
# - tmp-* = 单个问题的临时分支, 合到 dev-* 后删除
# - android-*= 只用于触发 APK 外壳构建, 不部署; 改动前须与 master 同步
#
# 历史: 之前 dev-* 与 master 共用同一套生产环境 (推送即发布), 容易把半成品带上线,
# 现已取消 dev-* 的 push 触发。
on:
push:
branches:
- main
- master
jobs:
@@ -17,6 +26,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 +39,16 @@ 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 会报成上一个版本的号
# (master 分支跳过分支前缀校验, 但四处一致性照样强制)
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 +67,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 +81,16 @@ jobs:
run: docker images
- name: Apply DB migrations
# 仓库内的 prisma 迁移自动应用 (migrate deploy 幂等, 无新迁移时为 no-op)
# 数据库自动升级: 按 prisma/migrations/ 下的迁移顺序应用 (生产库由 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
+3 -3
View File
@@ -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
+50 -45
View File
@@ -1,47 +1,52 @@
# 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/
# 本地 pnpm 安装产物 (项目使用 package-lock.json)
/.pnpm-store
@@ -205,24 +205,27 @@ server/routes/mihome.js # REST 路由 (authRequired)
### 5.2 认证与「重新授权」
- 启动时 `login()` 自动尝试加载/刷新 `DiskCache` 凭证;成功则 `status=ok`。
- 失败(30 天过期 / 风控)→ `MihomeAccount.status=expired`,前端弹授权横幅。
- 启动时加载 `DiskCache` 凭证,并用一次真实云请求(`getHomesList`)确认 token 仍有效;
只有该请求通过才 `status=ok`。
- 失败(30 天过期 / 风控 / 无凭证)→ `MihomeAccount.status=expired`,前端弹授权横幅 + 扫码窗。
- 重新授权流程(服务端驱动,前端轮询):
```
POST /api/mihome/reauthorize
└─ 若 pendingQr 空闲 => 后台启动 QRlogin 循环(每轮 120s 超时自动续二维码)
└─ 立即返回 { state, qrImageUrl }
└─ 若已有流程在跑 => 直接复用(同一张二维码继续有效)
└─ 否则后台启动 QRlogin 循环(每轮 120s 超时自动续二维码, 最多续 2 次)
└─ 立即返回 { running, done, state, qrImageUrl }
GET /api/mihome/reauthorize/status
└─ 返回 { state: idle|waiting|scanning|ok|failed, qrImageUrl?, lastError? }
└─ 扫码成功(qrlogin 返回) => 保存凭证 => 状态 ok => 触发一次立即同步
└─ 返回 { running, done, state: idle|waiting|failed, qrImageUrl?, lastError? }
└─ done=true 表示扫码成功、凭证已落盘、authCache 已置 ok
POST /api/mihome/reauthorize/cancel # 人工取消, 停掉后台循环
POST /api/mihome/reauthorize/cancel # 人工取消: 只让前端停止轮询, 后台长轮询自然结束
```
- `pendingQr` 为模块级单例,防并发(同一时间只允许一个扫码流程)。
- 前端在弹窗内 `setInterval 3s` 轮询直到 `ok`,成功后关弹窗并刷新列表。
- `qrState` 为模块级单例,防并发(同一时间只允许一个扫码流程)。
- 前端在弹窗内 `setInterval 3s` 轮询,遇 `done=true` 关弹窗并刷新列表;`failed` 则停轮询显示错误。
- 二维码图片接口在 `authRequired` 之后,`<img>` 带不上 JWT,前端必须 `fetch` 后转 `blob:` URL 显示。
### 5.3 采集器 collector
@@ -261,7 +264,7 @@ POST /api/mihome/reauthorize/cancel # 人工取消, 停掉后台循环
| 方法 | 路径 | 说明 |
| --- | --- | --- |
| GET | `/status` | 授权状态 + 家庭/设备(含别名、**电量、最新样本**、30 天缺口、统计可用性探测结果) |
| GET | `/status` | 授权状态 + 家庭/设备(含别名、**电量、最新样本**、30 天缺口、统计可用性探测结果);`?force=1` 跳过 5 分钟认证缓存强查一次 |
| GET | `/device/:did/history?range=today` | 当天逐小时(见 §5.6);缺省 `range=today` |
| GET | `/device/:did/history?range=month` | 近 30 天逐日(日均 + min/max) |
| GET | `/device/:did/history?range=year` | 近 52 周逐周(周均值 + min/max,服务端按日档聚合) |
@@ -297,7 +300,12 @@ POST /api/mihome/reauthorize/cancel # 人工取消, 停掉后台循环
1. **授权横幅**(`status !== ok`):
`米家授权已失效/待授权` + 【重新授权】按钮 → `Popup` 内 `<img src=qrImageUrl>`
3s 轮询 `/reauthorize/status` → 成功自动关闭并刷新。
3s 轮询 `/reauthorize/status` → `done=true` 自动关闭并刷新。
- `status=expired` 且非网络类错误时,进入页面**自动弹出一次**扫码窗(用户关掉后不再重复弹)。
- 二维码图片接口在 `authRequired` 后面,`<img src>` 带不上 JWT,必须用 `fetch` + `Authorization`
取二进制再转 `blob:` URL(见 `src/api/mihome.js` 的 `fetchMihomeQrImage()`),否则图片 401、弹窗里一片空白。
- 认证探测不能只看 `api.refreshToken()`:该库在本地凭证字段齐全时直接返回成功而不请求云端,
所以 `checkAuth()` 必须真打一次 `getHomesList()` 才能发现 30 天 token 过期。
2. **顶部操作**:`立即同步`(POST `/sync`)。
3. **家庭列表**:`antd-mobile <Collapse accordion>` 按家庭分组;组展开后显示该家庭
下的**环境设备卡片**;无环境设备的家庭 → 折叠标题旁标「无环境设备」占位。
@@ -0,0 +1,339 @@
# iBoard V0.2 任务模块 功能设计
| 项 | 内容 |
| --- | --- |
| 版本 | V0.2(对应分支 `dev-0.2`,应用版本 `0.2.0`) |
| 文档状态 | **设计基线**(待评审) |
| 上游文档 | `任务模块需求文档.md`(**149 条需求点**,编号 `T-*`) |
| 配套文档 | `任务模块设计文档-框架设计.md`、`任务模块数据库设计文档.md`、`任务模块界面设计.html` |
| 对应关系 | **本文与需求点一一对应**:每条需求点均给出「设计落点(功能/组件/接口/表字段)」与「实现要点」 |
> **阅读方式**
> 1. 本文按需求文档的模块码组织(`VW` / `COL` / `BD` / `FL` / `TK` / `AS` / `ST` / `PJ` / `PM` / `CP` / `UI` / `DT` / `NF` / `EX`)。
> 2. 每条需求点一行:**编号 → 设计落点 → 实现要点**。设计落点中的组件名、接口路径、表字段均与《任务模块数据库设计文档》《任务模块界面设计》一致。
> 3. 文末 §15 给出**覆盖自检表**,确保 149 条无遗漏。
---
## 0. 通用约定
### 0.1 契约命名
| 类别 | 约定 | 示例 |
| --- | --- | --- |
| 状态枚举 | `todo` / `doing` / `done` | 未开始 / 执行中 / 完成 |
| 列 | `BoardColumn` 实体,按 `sortOrder` 升序 | 默认 `Plan/Doing/Action/Done` |
| 保留选项 | `never` / `today` / `3d` / `week` / `month` / `forever` | 永不显示 / 当天 / 3 天 / 本周 / 本月 / 永久显示 |
| 视图 | `list` / `board` | 设备端记忆 |
| 设备端键 | `iboard_task_prefs` | `{ lastProjectId, viewMode: { [projectId]: 'list'\|'board' } }` |
### 0.2 接口总览
| 方法 | 路径 | 用途 |
| --- | --- | --- |
| GET | `/api/projects` | 我参与的项目(含个人默认项目、归档标记) |
| POST | `/api/projects` | 新建普通项目(同时初始化 4 个默认列) |
| GET | `/api/projects/:id` | 项目详情(含成员、列配置) |
| PUT | `/api/projects/:id` | 改名 / 归档 |
| DELETE | `/api/projects/:id` | 删除项目(个人项目 400) |
| POST | `/api/projects/:id/members` | 加成员(个人项目 400) |
| DELETE | `/api/projects/:id/members/:uid` | 移成员 / 自己退出 |
| GET/POST | `/api/projects/:id/columns` | 列列表 / 新增列 |
| PUT | `/api/columns/:id` | 改名 / 改绑定 / 改保留选项 |
| DELETE | `/api/columns/:id` | 删列(需 `moveToColumnId`,最后列 400) |
| PUT | `/api/projects/:id/columns/order` | 列排序 |
| GET | `/api/projects/:id/tasks` | 任务列表(含子任务与列信息) |
| POST | `/api/projects/:id/tasks` | 新建任务 / 子任务 |
| GET | `/api/tasks/:id` | 任务详情 |
| PUT | `/api/tasks/:id` | 改状态 / 换列 / 改字段(触发裁决) |
| DELETE | `/api/tasks/:id` | 删除任务(级联子任务) |
| POST | `/api/tasks/:id/copy` | 跨项目拷贝 |
---
## 1. 视图体系(T-VW)
| 编号 | 设计落点 | 实现要点 |
| --- | --- | --- |
| T-VW-01 | `ProjectView.jsx` 内 `viewMode` 分支渲染 `ListView` / `BoardView` | 两视图共用 `TaskStore` 的同一份任务数据,仅渲染方式不同 |
| T-VW-02 | `SwipeTabs`/分段控件切换 `viewMode` | 纯本地状态切换,不触发路由跳转、不重新拉取数据(满足"无整页刷新") |
| T-VW-03 | `prefs.js` → `viewMode[projectId]` | 按项目 id 分键存储,A/B 项目互不影响 |
| T-VW-04 | `ProjectView` 挂载时读 `prefs.getViewMode(projectId)` | 有值用之;无值走 T-VW-05 默认 |
| T-VW-05 | 默认值 `'list'` | 新项目首次进入为列表 |
| T-VW-06 | `localStorage.iboard_task_prefs` | **不落库、不同步**;换设备不继承(与 T-FL-15 的项目级配置区分) |
| T-VW-07 | `prefs.getLastProjectId()` / `setLastProjectId()` | 进入 `/task` 时读取;无记录或记录的项目已不可见 ⇒ 回落个人默认项目 |
| T-VW-08 | `TaskPage` 首屏上部 `RecentProjects` 条 | 取最近访问的 3 个项目(按设备端访问时间倒序),末尾「更多项目」入口 → `/task/list`(完整项目列表 `ProjectList`,从「更多」进入) |
| T-VW-09 | `TaskPage` 首屏下部直接内嵌 `ProjectView` | 当前项目 = 上次访问项目(无记录 ⇒ 个人默认项目);点首屏项目卡片切换当前项目,下部整体切换,不跳转页面 |
| T-VW-10 | `prefs.js` 增 `recentOrder[]`(设备端) | 长按卡片拖动排序并持久化;无手动记录时按最近访问时间自动排列;项目不可见时从记录剔除 |
> **首屏结构调整(T-VW-08/09)**:`TaskPage`(`/task`)不再以项目列表为首屏,改为「最近项目条 + 当前项目 `ProjectView`」;完整项目列表迁移至 `/task/list`。旧路由 `/task/:pid` 保留(打开指定项目),供深链使用。
> **实现细节**:`lastProjectId` 指向的项目若已被退出/删除,视为"无记录",静默回落到个人默认项目,不弹错。
---
## 2. 看板列管理(T-COL)
| 编号 | 设计落点 | 实现要点 |
| --- | --- | --- |
| T-COL-01 | `POST /api/projects` 内部初始化 4 列 | 事务内创建 `Plan(todo, sort 0)`、`Doing(doing,1)`、`Action(null,2)`、`Done(done,3)` |
| T-COL-02 | `ColumnSettings` → `POST /api/projects/:id/columns` | `sortOrder = max+1` 追加末尾;`boundStatus=null`、`retention='forever'`(B-18) |
| T-COL-03 | `PUT /api/columns/:id` | 改 `name`;前端 `TaskStore` 整体替换列列表后重渲染 |
| T-COL-04 | `DELETE /api/columns/:id?moveToColumnId=` | 二次确认弹窗;见 T-EX-16 |
| T-COL-05 | `PUT /api/projects/:id/columns/order` | 传完整 `[columnId…]` 数组,服务端按索引重写 `sortOrder` |
| T-COL-06 | 服务端校验总量 | `1 <= count <= 10`;达上限拒绝新增(T-EX-19)、仅剩 1 列拒绝删除(T-EX-17) |
| T-COL-07 | `BoardColumn.name` 校验 | trim 后非空、长度 ≤ 12;`@@unique([projectId,name])` 兜底重名 409 |
| T-COL-08 | 列删除即绑定消失 | 绑定存在列记录上,列删则解除,无需额外清理 |
| T-COL-09 | `requireProjectMember` | 项目级配置,任一成员可改;不影响其他项目 |
| T-COL-10 | `ListView` 不分组但仍显示列信息 | 任务行内以小标签显示所属列名(可关闭),归属始终正确 |
---
## 3. 看板与列-状态绑定(T-BD)
> 联动的**唯一实现**在 `server/task-domain/resolveStatus.js`,前端只做乐观更新。判定矩阵见《框架设计》§2.3。
| 编号 | 设计落点 | 实现要点 |
| --- | --- | --- |
| T-BD-01 | `BoardView` 按 `columns` 数组渲染列 | 列来自项目配置,可增删改排序 |
| T-BD-02 | `ColumnSettings` 每列一组「绑定开关 + 状态下拉」 | 写入 `BoardColumn.boundStatus`(`null`/`todo`/`doing`/`done`) |
| T-BD-03 | 无唯一约束限制多列绑定同状态 | 允许 `done` 与 `action` 同为 `done` |
| T-BD-04 | `onColumnChange()` | 目标列 `boundStatus != null` ⇒ `status = boundStatus` |
| T-BD-05 | `onStatusChange()` | 状态变更后若当前列已匹配则不动作,否则走归位 |
| T-BD-06 | 归位取列序首个匹配 | `columns` 按 `sortOrder` 排序后 `find()` 首个 |
| T-BD-07 | **只看目标列** | 目标列不绑定 ⇒ 保留原状态;**无"拖出回退"规则** |
| T-BD-08 | 不绑定列完全正交 | 拖入/拖出、改状态均不改该列以外语义 |
| T-BD-14 | `resolveColumn(task, newStatus)` | 当前列 `boundStatus !== newStatus` ⇒ 触发归位 |
| T-BD-15 | 按当前 `sortOrder` 取首个 | 用户调序后归位目标随之改变 |
| T-BD-16 | 无匹配返回 `null` | `null` ⇒ 不改 `columnId`,状态照常写库 |
| T-BD-09 | 初始化默认值 | `Plan=todo`、`Doing=doing`、`Done=done`、`Action=null` |
| T-BD-10 | 项目级配置 | 任一成员可改;不同项目独立 |
| T-BD-11 | 配置变更不回溯 | 保存配置时**不触发**任何任务更新语句 |
| T-BD-12 | 保存后返回最新列列表 | 前端整体替换 + 重算可见集合,无需刷新 |
| T-BD-13 | `resolveStatus()` 首行判 `parentId` | 子任务直接跳过全部联动 |
---
## 4. 已完成任务显示控制(T-FL)
| 编号 | 设计落点 | 实现要点 |
| --- | --- | --- |
| T-FL-01 | `BoardColumn.retention` | 每列独立字段 |
| T-FL-02 | `ColumnSettings` 下拉 6 项 | 固定枚举,无自由输入 |
| T-FL-03 | `retention='forever'` | 判定恒为可见 |
| T-FL-04 | `retention='never'` | 已完成任务恒不可见 |
| T-FL-05 | `retention='today'` | `doneAt >= 今天 00:00` |
| T-FL-06 | `retention='3d'` | `doneAt >= 今天-2天 00:00` |
| T-FL-07 | `retention='week'` | `doneAt >= 本周一 00:00` |
| T-FL-08 | `retention='month'` | `doneAt >= 本月1日 00:00` |
| T-FL-09 | `task-domain/retention.js` + 前端定时重算 | 超出窗口自动隐藏,无需操作 |
| T-FL-10 | 判定字段 `Task.doneAt` | 与当前列无关;取消完成时 `doneAt=null` |
| T-FL-11 | 前端可见性统一走同一函数 | 列表与看板共用 `isTaskVisible(task, column)`;列表不分组但归属不变 |
| T-FL-12 | 判定前置 `status !== 'done'` | 未完成任务恒可见 |
| T-FL-13 | 纯展示层过滤 | 不修改任何字段;接口返回全量,前端过滤(或服务端按需过滤但不改数据) |
| T-FL-14 | 列计数取过滤后集合长度 | 列头计数与卡片数同源,杜绝不一致 |
| T-FL-15 | 存在 `BoardColumn` 上 | 项目级共享,非个人偏好(对比 T-VW-06 设备端) |
> **实现细节**:保留判定在前后端各有一份实现(服务端用于分页/统计,前端用于即时过滤),但**必须共用同一口径常量**,跨度边界由 `date.js` 单测覆盖。
---
## 5. 任务字段体系(T-TK)
| 编号 | 设计落点 | 实现要点 |
| --- | --- | --- |
| T-TK-01 | `Task` 表 | 字段:`title`/`description`/`projectId`/`columnId`/`status`/`assigneeId`/`creatorId`/`doneAt`/`sortOrder` + 关联成员/标签/扩展 |
| T-TK-02 | `title` 非空校验 | 其余字段允许空;trim 后为空 ⇒ 400 |
| T-TK-03 | `status` 枚举三值 | DB 层用 `VARCHAR(8)` + 应用层校验(避免 ENUM 改值困难) |
| T-TK-04 | **已移除 UI**(2026-10-24):`ExtrasCard` 组件删除;信息记录由项目信息块(T-PJ-13)承载 |
| T-TK-05 | **随 T-TK-04 移除**;`extras` 字段在 DB 保留不再使用 |
| T-TK-06 | **随 T-TK-04 移除** |
| T-TK-07 | **随 T-TK-04 移除** |
| T-TK-08 | `Task.assigneeId` 单值 | 重复设置以最后一次为准 |
| T-TK-09 | 新建时服务端默认赋值 | 未显式传 `assigneeId` ⇒ 取 `req.user.id`(B-10) |
| T-TK-10 | `assigneeId` 允许 `null` | 显式传 `null` 即清空,UI 显示「未指派」 |
| T-TK-11 | `TaskMember` 关联表 | 多对多,`@@unique([taskId,userId])` 去重 |
| T-TK-12 | `TaskTag` 表 + 卡片标签渲染 | 多标签,展示于卡片/条目 |
| T-TK-13 | 服务端维护 | `creatorId`/`createdAt` 不可改;`updatedAt` 自动刷新 |
---
## 6. 负责人 & 成员(T-AS)
| 编号 | 设计落点 | 实现要点 |
| --- | --- | --- |
| T-AS-01 | `TaskCard` 负责人区 | 字号 13px/600 + 头像,位于标题下方主信息位 |
| T-AS-02 | `TaskCard` 成员区 | 字号 11px/400、色 `#999`,位于卡片底部 |
| T-AS-03 | `UserPicker` 数据源 = 项目成员 | 只列 `ProjectMember` 中的用户 |
| T-AS-04 | 同上(多选模式) | 不可选项目外用户 |
| T-AS-05 | `assigneeId === null` 时渲染「未指派」 | 占位样式弱化,可点击再次指派 |
| T-AS-06 | 渲染时 `LEFT JOIN ProjectMember` 判定 | 非成员 ⇒ 显示「已退出」灰标;见 T-EX-06 |
| T-AS-07 | 权限模型 | 无"负责人可编辑"概念,所有成员全读写(T-PM-02) |
---
## 7. 子任务体系(T-ST)
| 编号 | 设计落点 | 实现要点 |
| --- | --- | --- |
| T-ST-01 | `Task.parentId` 自关联 | 创建子任务时校验:父任务 `parentId IS NULL`,否则 400 |
| T-ST-02 | 一致性校验 | 子任务 `projectId` 必须等于父任务 `projectId`,否则 400(T-EX-12) |
| T-ST-03 | 状态独立 | 父/子状态字段各自独立,无级联更新 |
| T-ST-04 | `SubtaskRow` 缩进 | 左侧 28px 缩进 + 层级线 |
| T-ST-05 | 折叠/展开按钮 | 组件内存状态,折叠时不渲染子项 |
| T-ST-06 | `TaskCard` 内嵌 `SubtaskRow` 列表 | 简洁单行:复选框 + 标题,最多显示 3 条 + 「查看全部」 |
| T-ST-07 | 卡片内直接勾选 | 勾选调 `PUT /api/tasks/:id {status}`;完整编辑走 `TaskDetail` 弹窗 |
| T-ST-08 | 子任务跳过绑定 | 见 T-BD-13;父任务完成时子任务状态不变 |
| T-ST-09 | `SubtaskRow` 带 `StatusDot` | 三色小圆点:灰=未开始 / 蓝=执行中 / 绿=完成 |
| T-ST-10 | 看板只渲染顶层任务 | 看板查询 `WHERE parentId IS NULL` |
| T-ST-11 | `Task.parentId` 级联删除 | `onDelete: Cascade`(T-EX-07) |
| T-ST-12 | `useLongPressDrag` 落点为目标顶层任务 | 设置 `parentId` |
| T-ST-13 | 目标为子任务 ⇒ 拒绝 | Toast「仅支持一级子任务」 |
| T-ST-14 | 被拖任务有子任务 ⇒ 拒绝 | 避免二级嵌套 |
| T-ST-15 | 保持原列与状态 | 仅更新 `parentId`,不触发状态裁决 |
---
## 8. 项目体系(T-PJ)
| 编号 | 设计落点 | 实现要点 |
| --- | --- | --- |
| T-PJ-01 | 用户首次访问自动创建 | 按 `isPersonal=true` 且 `ownerId=userId` 幂等创建 |
| T-PJ-02 | `DELETE /api/projects/:id` 校验 `isPersonal` | 为真 ⇒ 400(T-EX-09) |
| T-PJ-03 | `POST .../members` 校验 `isPersonal` | 为真 ⇒ 400 |
| T-PJ-04 | `name` 初始 `${user.name}的任务` | 允许 `PUT` 改名,`isPersonal` 不变 |
| T-PJ-05 | `POST /api/projects` | 创建者自动写入 `ProjectMember` |
| T-PJ-06 | 成员接口 | 增/删成员,即时影响可见性 |
| T-PJ-07 | `DELETE .../members/:uid` 允许 `uid == ownerId` | 创建人退出后不再是成员 ⇒ 不可见 |
| T-PJ-08 | 删除最后成员不删项目 | 仅删 `ProjectMember`(B-11);见 T-EX-11 |
| T-PJ-09 | `Project.archived` | 列表分「进行中 / 已归档」 |
| T-PJ-10 | 列表查询按成员关系过滤 | `WHERE EXISTS ProjectMember(userId)` |
| T-PJ-11 | 新组件 `ProjectManage`(项目管理页) | 分区承载:成员(复用 `ProjectMembers` 逻辑)、列设置(复用 `ColumnSettings`)、项目信息;`ProjectView` 头部仅保留「项目管理」一个入口 |
| T-PJ-12 | `ProjectManage` 的「项目信息」分区 | 改名(`PUT /api/projects/:id {name}`)、归档/取消归档(`{archived}`)、删除(个人项目隐藏入口)、退出(仅创建人可见);删除与退出均有二次确认 |
| T-PJ-13 | `Project.notes` Json + `ProjectManage` 的「信息块」分区 | 存 `[{title, content}]`(≤50 块,标题 ≤50 字、内容 ≤20000 字);`PUT {notes}` 整体替换;底部弹层独立编辑(标题输入 + 多行 textarea + 删除),键盘弹出时复用 `useKeyboardInset` 抬升 |
---
## 9. 项目权限与入口(T-PM)
| 编号 | 设计落点 | 实现要点 |
| --- | --- | --- |
| T-PM-01 | `requireProjectMember` 中间件 | 非成员 403;列表查询天然过滤 |
| T-PM-02 | 无角色区分 | 所有成员对任务/列/成员均有全读写 |
| T-PM-03 | `ProjectMember.permission` 字段 | 本期恒为 `member`,预留分级扩展位 |
| T-PM-04 | 归档接口无权限限制 | 任意成员可归档(`PUT /api/projects/:id {archived}`) |
| T-PM-05 | 复用 `authRequired` | 未登录 401 |
| T-PM-06 | `BottomNav.TABS` 插入 `{ key:'/task', label:'任务' }` | 位于 `打卡` 之后;`HomePath` 仍为 `/checkin` |
| T-PM-07 | `prefs.lastProjectId` | 设备端;无记录 ⇒ 个人默认项目 |
---
## 10. 跨项目拷贝(T-CP)
| 编号 | 设计落点 | 实现要点 |
| --- | --- | --- |
| T-CP-01 | `POST /api/tasks/:id/copy` | 复制而非移动,源任务零改动 |
| T-CP-02 | `copyTask()` 字段映射 | 标题/描述/状态/列/扩展/标签/成员 全量复制 |
| T-CP-03 | 递归复制子任务 | 只复制一层(子任务无更深层级) |
| T-CP-04 | `creatorId = req.user.id` | 覆盖源创建人 |
| T-CP-05 | 无任何关联字段 | 拷贝后两条独立记录 |
| T-CP-06 | 校验目标项目成员身份 | 非成员 403 |
| T-CP-07 | 人员交集过滤 | 保留属于目标项目成员者,返回 `dropped` 名单(T-EX-08) |
| T-CP-08 | 子任务 `projectId` 同步改写 | 与父任务一致 |
| T-CP-09 | 状态与列沿用不纠正 | 见 T-EX-14 |
---
## 11. UI 交互规范(T-UI)
| 编号 | 设计落点 | 实现要点 |
| --- | --- | --- |
| T-UI-01 | 列表条目结构 / 快速新增 / 子任务折叠 | 参考 Todoist:底部输入框回车即建 |
| T-UI-02 | `TaskCard` 精简版 | 仅标题、状态点、负责人、子任务摘要;无边框装饰 |
| T-UI-03 | 单 `TaskStore` | 任一视图修改写入 store,另一视图读取同一数据 |
| T-UI-04 | 视图切换纯本地 | 不重新请求,不重载页面 |
| T-UI-05 | 复用 `.card` 与安全区变量 | 375~430px 无横向滚动;底部预留 TabBar 高度 |
| T-UI-06 | 勾选即时变色 | 乐观更新,先改 UI 再发请求 |
| T-UI-07 | `useLongPressDrag` | 长按 300ms 拖拽 + 「⋯」菜单「移动到」兜底 |
| T-UI-08 | `EmptyState` 组件 | 无项目 / 无任务 / 无子任务三档空态 + CTA |
| T-UI-09 | `LoadingState` 骨架屏 | 首屏与切换视图 |
| T-UI-10 | `ErrorState` 组件 | 展示错误 + 重试按钮 |
| T-UI-11 | 弹层容器统一 `padding-bottom: calc(env(safe-area-inset-bottom) + 20px)` | 底部按钮(删除等)与屏幕底边保持安全距离;小屏下弹层 `max-height` + 内部滚动,底部按钮始终完整可见 |
---
## 12. 数据需求(T-DT)
| 编号 | 设计落点 | 实现要点 |
| --- | --- | --- |
| T-DT-01 | `Project` / `ProjectMember` | 见《数据库设计文档》 |
| T-DT-02 | `BoardColumn.boundStatus` | 每列独立字段,允许多列同值 |
| T-DT-03 | `BoardColumn.retention` | 六档枚举字符串 |
| T-DT-04 | `Task` | 含 `parentId` 自关联、`assigneeId`、`status`、`columnId`、`extras` |
| T-DT-05 | `TaskMember` / `TaskTag` | 关联表;`extras` 为 JSON |
| T-DT-06 | 设备端 | 不落库 |
| T-DT-07 | 索引 | `(projectId, columnId)`、`(projectId, status)`、`(parentId)` |
| T-DT-08 | 迁移文件 | `prisma migrate deploy` 流程 |
| T-DT-09 | `Project.archived` / 任务无物理删 | 归档保留历史 |
---
## 13. 非功能需求(T-NF)
| 编号 | 设计落点 | 实现要点 |
| --- | --- | --- |
| T-NF-01 | 单请求聚合 + 乐观更新 | 见《框架设计》§4.2 |
| T-NF-02 | 不依赖桌面专有 API | 触控事件统一 `touch` / `pointer` 兼容 |
| T-NF-03 | `src/pages/task/*` + `server/routes/*.js` | 与既有模块结构一致 |
| T-NF-04 | 路由挂载于 `server/index.js` | 沿用既有组织方式 |
| T-NF-05 | 千级任务可用 | 索引 + 分页/虚拟滚动 |
---
## 14. 异常与边界(T-EX)
| 编号 | 设计落点 | 实现要点 |
| --- | --- | --- |
| T-EX-01 | 前端 500ms 防抖 + 服务端幂等判定 | 相同终态请求直接返回当前任务 |
| T-EX-02 | 乐观更新快照回滚 | 失败恢复快照 + Toast + 重试按钮 |
| T-EX-03 | 列配置改为不绑定 | 已有任务保持不动 |
| T-EX-04 | 状态与列不匹配 | 走 T-BD-14~16 归位 |
| T-EX-05 | 拖到不绑定列 | 状态保留,不回退 |
| T-EX-06 | 非成员渲染「已退出」 | 见 T-AS-06 |
| T-EX-07 | 级联删除子任务 | 确认弹窗提示条数 |
| T-EX-08 | 拷贝丢弃人员 | 返回 `dropped` 并提示 |
| T-EX-09 | 个人项目两个保护接口 | 400 + 明确文案 |
| T-EX-10 | 列空态 | 展示该列空态,不报错 |
| T-EX-11 | 无成员项目 | 静默不可见,无特判报错 |
| T-EX-12 | 子任务跨项目 | 服务端 400 |
| T-EX-13 | 拖拽降级 | 保持原列与状态 |
| T-EX-14 | 拷贝状态/列无法对应 | 沿用源值,不纠正 |
| T-EX-15 | 拖到自身或后代 | 拒绝 + Toast |
| T-EX-16 | 删除有任务的列 | 二次确认 + 迁移到指定列(默认相邻)+ 重新裁决 |
| T-EX-17 | 删除最后一列 | 400「至少保留一列」 |
| T-EX-18 | 跨周期当场消失 | 定时重算 + 退场动画 + 重排 |
| T-EX-19 | 达到列数上限 | 拒绝新增 + 提示 |
---
## 15. 覆盖自检表
| 模块 | 编号范围 | 条数 | 本文对应章节 |
| --- | --- | --- | --- |
| 视图体系 | T-VW-01 ~ 10 | 10 | §1 |
| 看板列管理 | T-COL-01 ~ 10 | 10 | §2 |
| 列-状态绑定 | T-BD-01 ~ 16 | 16 | §3 |
| 已完成显示控制 | T-FL-01 ~ 15 | 15 | §4 |
| 任务字段体系 | T-TK-01 ~ 13 | 13 | §5 |
| 负责人 & 成员 | T-AS-01 ~ 07 | 7 | §6 |
| 子任务体系 | T-ST-01 ~ 15 | 15 | §7 |
| 项目体系 | T-PJ-01 ~ 13 | 13 | §8 |
| 项目权限与入口 | T-PM-01 ~ 07 | 7 | §9 |
| 跨项目拷贝 | T-CP-01 ~ 09 | 9 | §10 |
| UI 交互规范 | T-UI-01 ~ 11 | 11 | §11 |
| 数据需求 | T-DT-01 ~ 09 | 9 | §12 |
| 非功能需求 | T-NF-01 ~ 05 | 5 | §13 |
| 异常与边界 | T-EX-01 ~ 19 | 19 | §14 |
| **合计** | — | **149** | — |
> 需求文档共 **149 条**需求点,本文逐条给出设计落点,覆盖率 **149/149**。(其中 `T-BD-14~16`、`T-COL-*`、`T-EX-18~19`、`T-VW-08~10`、`T-PJ-11/12`、`T-UI-11` 为评审后新增,编号不连续,非笔误。)
@@ -0,0 +1,443 @@
# iBoard V0.2 任务模块 数据库设计文档
| 项 | 内容 |
| --- | --- |
| 版本 | V0.2(对应分支 `dev-0.2`,应用版本 `0.2.0`) |
| 文档状态 | **设计基线**(待评审,未落库) |
| 数据库 | MariaDB / MySQL 8(Prisma `provider = "mysql"`) |
| 字符集 | `utf8mb4 / utf8mb4_unicode_ci` |
| 时区约定 | 应用层统一 `Asia/Shanghai`,库内 `DATETIME` 存 UTC |
| 相关文件 | `prisma/schema.prisma`、`prisma/migrations/20261024xxxxxx_add_task_module/migration.sql` |
| 关联文档 | `任务模块需求文档.md`、`任务模块设计文档-框架设计.md`、`任务模块功能设计.md`、`任务模块界面设计.html` |
---
## 1. 概述
任务模块新增 **6 张表**,并把既有 `User` 表补上反向关系(不改 `User` 结构):
| 表 | 作用 | 归属 |
| --- | --- | --- |
| `Project` | 项目(隔离与权限单位) | 项目域 |
| `ProjectMember` | 项目成员(权限关系) | 项目域 |
| `BoardColumn` | 看板列(名称 / 顺序 / 状态绑定 / 保留选项) | 看板域 |
| `Task` | 任务与子任务(自关联,一层) | 任务域 |
| `TaskMember` | 任务成员(多人标记) | 任务域 |
| `TaskTag` | 任务标签 | 任务域 |
### 1.1 设计原则
1. **列是实体,不是枚举。** 看板列允许增删改与排序(T-COL),若仍用 `plan/doing/action/done` 枚举,则"改名"无从表达、"排序"只能堆在 JSON 里、"删除列"会留下悬空字符串。因此独立建 `BoardColumn` 表,`Task` 用 `columnId` 外键引用。
2. **状态与列双落库,互不推导。** `Task.status`(三值)与 `Task.columnId` 是两个正交维度;未绑定的列(默认 `Action`)必须允许二者不一致(T-BD-08、T-BD-16),任何"由状态反推列"或"由列反推状态"的写法都会丢失信息。联动只在写接口中按项目配置即时裁决。
3. **配置不下沉到任务。** 绑定与保留选项只存在 `BoardColumn` 上,任务不冗余任何配置副本——改配置立即对所有任务生效,且不需要回填。
4. **按项目隔离。** 所有业务表都可通过 `projectId` 归集,权限校验统一查 `ProjectMember`。
5. **软删除优先。** 项目用 `archived`;任务删除为级联物理删除(T-ST-11),但删除前有二次确认。
6. **迁移只增不改。** 新增需求一律新迁移文件,不修改已执行的迁移。
---
## 2. 概念模型(ER)
```
┌────────────────┐
│ User │ (既有表, 仅补反向关系)
└───┬────────┬───┘
│ │
┌──────────▼──┐ ┌──▼──────────────┐
│ProjectMember│ │ TaskMember │
│(权限关系) │ │ (任务成员) │
└──────┬──────┘ └──▲──────────────┘
│ * │ *
│ │
┌───▼──────┐ ┌─┴──────┐
│ Project │1─*│ Task │*─1┌────────────┐
└───┬──────┘ └───┬────┘ │ BoardColumn│
│ * │ * └────────────┘
┌────▼───────┐ │ ▲
│BoardColumn │1────*┘ │
└────────────┘ (columnId) │
│
┌────────┐ │
│TaskTag │*──────────────────┘
└────────┘
Task 自关联: parentId ── 子任务(一层, 仅顶层任务可作父)
```
基数说明:
| 关系 | 基数 | 级联 |
| --- | --- | --- |
| User → ProjectMember | 1 : N | Cascade |
| Project → ProjectMember | 1 : N | Cascade |
| Project → BoardColumn | 1 : N | Cascade |
| Project → Task | 1 : N | Cascade |
| BoardColumn → Task | 1 : N | **RESTRICT**(有任务的列不可直接删) |
| Task → Task(parent) | 1 : N | Cascade |
| Task → TaskMember | 1 : N | Cascade |
| Task → TaskTag | 1 : N | Cascade |
| User → Task(assignee / creator) | 1 : N | SetNull / Cascade(见下) |
> `BoardColumn → Task` 使用 `RESTRICT` 而非 `Cascade`:防止误删列时静默丢任务。删除列必须先在其接口内把任务迁移走(事务内改 `columnId`),迁移完成后再删列。
---
## 3. 表结构
### 3.1 `Project` — 项目
| 字段 | 类型 | 空 | 默认 | 说明 |
| --- | --- | --- | --- | --- |
| `id` | `INT` PK AI | 否 | — | 主键 |
| `name` | `VARCHAR(50)` | 否 | — | 项目名 |
| `ownerId` | `INT` | 否 | — | 创建人(可退出项目,故与成员表分离) |
| `isPersonal` | `BOOLEAN` | 否 | `false` | 个人默认项目:不可删、不可加成员(T-PJ-02/03) |
| `archived` | `BOOLEAN` | 否 | `false` | 归档(T-PJ-09) |
| `createdAt` | `DATETIME(3)` | 否 | `now()` | — |
| `updatedAt` | `DATETIME(3)` | 否 | — | `@updatedAt` |
索引:
| 名称 | 字段 | 用途 |
| --- | --- | --- |
| `idx_project_owner` | `(ownerId)` | 查"我的项目" |
| `idx_project_archived` | `(archived)` | 归档分区 |
**约束说明**
- `isPersonal` 唯一性由应用层保证(每个用户至多一个):`UNIQUE(ownerId, isPersonal)` 不可用(普通项目也是 `false`),改为在创建时查询判重。
### 3.2 `ProjectMember` — 项目成员
| 字段 | 类型 | 空 | 默认 | 说明 |
| --- | --- | --- | --- | --- |
| `id` | `INT` PK AI | 否 | — | — |
| `projectId` | `INT` | 否 | — | FK → `Project.id` `ON DELETE CASCADE` |
| `userId` | `INT` | 否 | — | FK → `User.id` `ON DELETE CASCADE` |
| `permission` | `VARCHAR(16)` | 否 | `'member'` | 预留权限分级(T-PM-03),本期恒为 `member` |
| `createdAt` | `DATETIME(3)` | 否 | `now()` | 加入时间 |
索引:
| 名称 | 字段 | 唯一 | 用途 |
| --- | --- | --- | --- |
| `uk_member_project_user` | `(projectId, userId)` | ✔ | 防重复加入;权限查询 |
| `idx_member_user` | `(userId)` | — | "我参与的项目" |
> **注意**:视图记忆与上次项目**不在此表**。视图记忆存设备端(T-VW-06、T-DT-06),早期草案把它放在 `ProjectMember` 上,已在评审中否决。
### 3.3 `BoardColumn` — 看板列
**本模块新增的核心实体**,承载三件事:显示顺序、状态绑定、已完成保留选项。
| 字段 | 类型 | 空 | 默认 | 说明 |
| --- | --- | --- | --- | --- |
| `id` | `INT` PK AI | 否 | — | — |
| `projectId` | `INT` | 否 | — | FK → `Project.id` `ON DELETE CASCADE` |
| `name` | `VARCHAR(20)` | 否 | — | 列名,项目内唯一(T-COL-07) |
| `sortOrder` | `INT` | 否 | `0` | 显示顺序;**决定自动归位取"第一个匹配列"**(T-BD-15) |
| `boundStatus` | `VARCHAR(8)` | **是** | `NULL` | `NULL`=不绑定;或 `todo`/`doing`/`done`(T-BD-02) |
| `retention` | `VARCHAR(8)` | 否 | `'forever'` | `never`/`today`/`3d`/`week`/`month`/`forever`(T-FL-02) |
| `createdAt` | `DATETIME(3)` | 否 | `now()` | — |
| `updatedAt` | `DATETIME(3)` | 否 | — | — |
索引:
| 名称 | 字段 | 唯一 | 用途 |
| --- | --- | --- | --- |
| `uk_column_project_name` | `(projectId, name)` | ✔ | 列名不重名(T-COL-07) |
| `idx_column_project_sort` | `(projectId, sortOrder)` | — | 看板渲染与归位查找 |
**默认数据**(新建项目时由应用层在事务内写入,T-COL-01、T-BD-09):
| sortOrder | name | boundStatus | retention |
| --- | --- | --- | --- |
| 0 | `Plan` | `todo` | `forever` |
| 1 | `Doing` | `doing` | `forever` |
| 2 | `Action` | `NULL` | `forever` |
| 3 | `Done` | `done` | `forever` |
> **为什么保留选项默认为 `forever` 而不是 `never`**:默认应"看得见",避免用户误以为数据丢失;隐藏是主动行为。
**约束与校验**
| 规则 | 实现层 |
| --- | --- |
| 列数 1~10(上限待确认,T-COL-06) | 应用层:新增/删除前计数校验 |
| 至少保留 1 列(T-EX-17) | 应用层:删除前 count > 1 |
| 删列必须指定迁移目标(T-EX-16) | 应用层:接口必须带 `moveToColumnId` |
| `boundStatus` 取值合法 | 应用层白名单校验 |
| `retention` 取值合法 | 应用层白名单校验 |
### 3.4 `Task` — 任务 / 子任务
| 字段 | 类型 | 空 | 默认 | 说明 |
| --- | --- | --- | --- | --- |
| `id` | `INT` PK AI | 否 | — | — |
| `projectId` | `INT` | 否 | — | FK → `Project.id` `ON DELETE CASCADE` |
| `columnId` | `INT` | 否 | — | FK → `BoardColumn.id` `ON DELETE RESTRICT`(T-BD-01) |
| `parentId` | `INT` | **是** | `NULL` | 非空 = 一级子任务;FK → `Task.id` `ON DELETE CASCADE`(T-ST-01/11) |
| `title` | `VARCHAR(200)` | 否 | — | 唯一必填(T-TK-02) |
| `description` | `TEXT` | 否 | `''` | — |
| `status` | `VARCHAR(8)` | 否 | `'todo'` | `todo`/`doing`/`done`(T-TK-03) |
| `doneAt` | `DATETIME(3)` | **是** | `NULL` | 完成时刻;**保留选项判定依据**(T-FL-10) |
| `assigneeId` | `INT` | **是** | `NULL` | 负责人(单人);新建默认创建者(T-TK-09) |
| `creatorId` | `INT` | 否 | — | 创建人;拷贝时为操作者(T-CP-04) |
| `extras` | `JSON` | 否 | `[]` | 扩展信息卡片 `[{label,value,url?}]`(T-TK-04/05) |
| `sortOrder` | `INT` | 否 | `0` | 列内/列表内排序 |
| `createdAt` | `DATETIME(3)` | 否 | `now()` | — |
| `updatedAt` | `DATETIME(3)` | 否 | — | — |
外键与索引:
| 名称 | 字段 | 类型 | 说明 |
| --- | --- | --- | --- |
| `fk_task_project` | `(projectId)` | Cascade | 删项目连带删任务 |
| `fk_task_column` | `(columnId)` | **Restrict** | 有任务的列不可直接删(见 §2 说明) |
| `fk_task_parent` | `(parentId)` | Cascade | 删父任务连带删子任务(T-EX-07) |
| `fk_task_assignee` | `(assigneeId)` | SetNull | 用户删除时清空负责人 |
| `fk_task_creator` | `(creatorId)` | Cascade | 用户删除时连带删其创建的任务* |
| `idx_task_project_column` | `(projectId, columnId)` | — | 看板按列查询 |
| `idx_task_project_status` | `(projectId, status)` | — | 列表/统计 |
| `idx_task_parent` | `(parentId)` | — | 取子任务 |
| `idx_task_done_at` | `(projectId, doneAt)` | — | 保留窗口计算 |
> \* `creatorId` 用 Cascade 会丢失"他人创建的任务"的历史;更稳妥的做法是 `Restrict` 并禁止删除有任务的用户。**建议改为 `Restrict`**,与既有 `User` 删除行为保持一致(本期系统无删除用户入口)。
**校验规则**
| 规则 | 位置 |
| --- | --- |
| `title` trim 后非空且 ≤ 200 | 应用层 |
| `parentId` 非空时,其父任务 `parentId` 必须为空(禁止二级,T-ST-01) | 应用层 |
| 子任务 `projectId` == 父任务 `projectId`(T-EX-12) | 应用层 + 事务内校验 |
| `parentId` 不得指向自身或其后代(T-EX-15) | 应用层 |
| `status='done'` 时写 `doneAt=now()`;退出 `done` 时置 `NULL` | 应用层(领域统一裁决) |
| `assigneeId` 必须是 `ProjectMember` 中的用户(T-AS-03) | 应用层 |
### 3.5 `TaskMember` — 任务成员
| 字段 | 类型 | 空 | 默认 | 说明 |
| --- | --- | --- | --- | --- |
| `id` | `INT` PK AI | 否 | — | — |
| `taskId` | `INT` | 否 | — | FK → `Task.id` `ON DELETE CASCADE` |
| `userId` | `INT` | 否 | — | FK → `User.id` `ON DELETE CASCADE` |
索引:`uk_task_member (taskId, userId)` 唯一。
### 3.6 `TaskTag` — 任务标签
| 字段 | 类型 | 空 | 默认 | 说明 |
| --- | --- | --- | --- | --- |
| `id` | `INT` PK AI | 否 | — | — |
| `taskId` | `INT` | 否 | — | FK → `Task.id` `ON DELETE CASCADE` |
| `name` | `VARCHAR(20)` | 否 | — | 标签名 |
索引:`idx_tag_task (taskId)`。
---
## 4. Prisma Schema(草案)
```prisma
model Project {
id Int @id @default(autoincrement())
name String @db.VarChar(50)
ownerId Int
isPersonal Boolean @default(false)
archived Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
owner User @relation("ProjectOwner", fields: [ownerId], references: [id])
members ProjectMember[]
columns BoardColumn[]
tasks Task[]
@@index([ownerId])
@@index([archived])
@@map("Project")
}
model ProjectMember {
id Int @id @default(autoincrement())
projectId Int
userId Int
permission String @default("member") @db.VarChar(16)
createdAt DateTime @default(now())
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([projectId, userId])
@@index([userId])
@@map("ProjectMember")
}
model BoardColumn {
id Int @id @default(autoincrement())
projectId Int
name String @db.VarChar(20)
sortOrder Int @default(0)
boundStatus String? @db.VarChar(8) // null | todo | doing | done
retention String @default("forever") @db.VarChar(8)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
tasks Task[]
@@unique([projectId, name])
@@index([projectId, sortOrder])
@@map("BoardColumn")
}
model Task {
id Int @id @default(autoincrement())
projectId Int
columnId Int
parentId Int?
title String @db.VarChar(200)
description String @default("") @db.Text
status String @default("todo") @db.VarChar(8)
doneAt DateTime?
assigneeId Int?
creatorId Int
extras Json @default("[]")
sortOrder Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
column BoardColumn @relation(fields: [columnId], references: [id], onDelete: Restrict)
parent Task? @relation("Subtask", fields: [parentId], references: [id], onDelete: Cascade)
children Task[] @relation("Subtask")
assignee User? @relation("TaskAssignee", fields: [assigneeId], references: [id], onDelete: SetNull)
creator User @relation("TaskCreator", fields: [creatorId], references: [id], onDelete: Restrict)
members TaskMember[]
tags TaskTag[]
@@index([projectId, columnId])
@@index([projectId, status])
@@index([parentId])
@@index([projectId, doneAt])
@@map("Task")
}
model TaskMember {
id Int @id @default(autoincrement())
taskId Int
userId Int
task Task @relation(fields: [taskId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([taskId, userId])
@@map("TaskMember")
}
model TaskTag {
id Int @id @default(autoincrement())
taskId Int
name String @db.VarChar(20)
task Task @relation(fields: [taskId], references: [id], onDelete: Cascade)
@@index([taskId])
@@map("TaskTag")
}
```
`User` 需补反向关系(不改字段):
```prisma
model User {
// …既有字段…
ownedProjects Project[] @relation("ProjectOwner")
projectMembers ProjectMember[]
assignedTasks Task[] @relation("TaskAssignee")
createdTasks Task[] @relation("TaskCreator")
taskMembers TaskMember[]
}
```
---
## 5. 关键查询
| 场景 | 查询 |
| --- | --- |
| 我的项目 | `SELECT p.* FROM Project p JOIN ProjectMember m ON m.projectId=p.id WHERE m.userId=? AND NOT p.archived` |
| 项目详情 + 列 | `SELECT * FROM BoardColumn WHERE projectId=? ORDER BY sortOrder` |
| 看板任务(含子任务) | 顶层:`WHERE projectId=? AND parentId IS NULL`;子任务:`WHERE parentId IN (...)` 一次取回后在内存分组(避免 N+1) |
| 权限校验 | `SELECT 1 FROM ProjectMember WHERE projectId=? AND userId=?` |
| 保留窗口过滤 | 服务端可选:`status <> 'done' OR (doneAt >= ?)`;前端另有一份同口径实现 |
| 自动归位选列 | 内存中对已加载的 `columns`(按 sortOrder)`find(c => c.boundStatus === status)` |
**防 N+1**:一次请求返回 `{ project, columns, members, tasks }`,子任务与父任务同批返回,由前端按 `parentId` 分组。
---
## 6. 迁移与初始化
### 6.1 迁移文件
`prisma/migrations/20261024xxxxxx_add_task_module/migration.sql`:
1. 建 `Project`、`ProjectMember`、`BoardColumn`、`Task`、`TaskMember`、`TaskTag` 六张表;
2. 建外键(注意 `Task.columnId` 为 `RESTRICT`);
3. 建唯一索引与普通索引;
4. **不做数据回填**(本模块为新功能,无历史数据)。
### 6.2 个人默认项目初始化
```sql
-- 应用层逻辑(伪 SQL),首次访问 /api/projects 时执行
INSERT INTO Project (name, ownerId, isPersonal, createdAt, updatedAt)
SELECT CONCAT(u.name, '的任务'), u.id, 1, NOW(3), NOW(3)
FROM User u
WHERE u.id = ?
AND NOT EXISTS (SELECT 1 FROM Project p WHERE p.ownerId = u.id AND p.isPersonal = 1);
-- 随后为该项目的 4 个默认列插入 BoardColumn(同事务)
```
### 6.3 新建普通项目
同事务内:建 `Project`(`isPersonal=0`)→ 写 `ProjectMember(owner)` → 写 4 个默认 `BoardColumn`。**任一步失败整体回滚**,避免出现"无列项目"。
---
## 7. 容量与性能估算
| 项 | 估算 | 依据 |
| --- | --- | --- |
| 单项目任务 | 千级 / 年 | T-NF-05 |
| 单项目列 | ≤ 10 | T-COL-06 |
| `Task` 行宽 | ~400 B + JSON | 含 `extras` |
| 单项目年增量 | ≈ 400 KB | 1k 任务 × 400B |
| 索引开销 | 约表大小 30% | 4 个二级索引 |
**无需分表**;按 `projectId` 的查询均可走索引。
---
## 8. 与既有模块的关系
| 项 | 说明 |
| --- | --- |
| `User` 表 | 仅补反向关系,**不改结构**,不影响打卡/宏观/监控模块 |
| 打卡模块(`Habit`/`CheckIn`) | 完全独立,同为"按用户隔离";任务模块改为"按项目隔离" |
| 迁移顺序 | 在 `20261022120000_add_checkin_checklist` 之后追加,互不依赖 |
| 时区 | 沿用 Asia/Shanghai 应用层口径;`doneAt` 用 `DATETIME(3)` 存 UTC |
---
## 9. 未决事项
| # | 事项 | 当前处理 |
| --- | --- | --- |
| 1 | 列数上限是否 10 | 按 10 设计(T-COL-06 标注"待确认"),若定其他值只改常量 |
| 2 | `Task.creatorId` 外键行为 | 草案建议 `Restrict`(见 §3.4 注),需与"用户删除策略"一并确认 |
| 3 | 孤儿项目(无成员)的维护 | 本期不处理(T-PJ-08),由后台流程清理;数据库层保留数据 |
| 4 | `extras` 条目数量上限 | 建议 ≤ 20,未在需求中约束,设计预留 |
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,451 @@
# iBoard V0.2 任务模块 设计文档 — 框架设计
| 项 | 内容 |
| --- | --- |
| 版本 | V0.2(对应分支 `dev-0.2`,应用版本 `0.2.0`) |
| 文档状态 | **设计基线**(待评审;不含实现代码) |
| 上游文档 | `任务模块需求文档.md`(需求编号 `T-*` 原文见该文档,本文按编号逐条对应) |
| 配套文档 | `任务模块功能设计.md`(功能设计,需求点一一对应)、`任务模块数据库设计文档.md`、`任务模块界面设计.html` |
| 技术栈 | Tauri 2 + React 18 + antd-mobile + React Router v6 + Express 4 + Prisma / MariaDB |
| 设计基线 | 移动端优先,视口 420×820,375~430px 自适应;时区统一 `Asia/Shanghai` |
> **编号约定**:本文多处出现需求编号(如 `T-BD-14`),其完整描述与验收要点以《任务模块需求文档》为准。每个模块的「需求点 → 设计落点」对照见《任务模块功能设计》。
---
## 1. 总体设计
### 1.1 目标与范围
在既有 iBoard 之上新增**任务模块**,并把它作为**顶级 Tab 插入「打卡」之后**(顺序:打卡 → **任务** → 宏观 → 监控 → 我,默认首页仍为打卡,T-PM-06)。
模块解决的问题与核心隐喻:
| 视图 / 概念 | 解决的问题 | 核心隐喻 |
| --- | --- | --- |
| **列表视图** | 快速记录、逐条推进 | Todoist 式条目 + 一级子任务缩进 |
| **看板视图** | 多人分工下的阶段流转 | 列(阶段)× 卡片(任务),长按拖拽换列 |
| **项目** | 数据与权限的隔离单位 | 项目即"工作区",成员可见即可读写 |
| **列-状态绑定** | 让"拖动列"与"改状态"两个动作不必重复做 | 列是**空间**,状态是**属性**,绑定是二者之间的**可选桥** |
| **列保留选项** | 已完成任务不淹没在列表里 | 每列自己的"保质期" |
**设计的关键取舍**:把「看板列」与「任务状态」设计成**两个独立维度**,再用**项目级、逐列的绑定配置**把它们关联起来。这样既支持"拖动即改状态"的一体化体验,也支持"这一列只是分类、不进状态机"的轻量用法——这正是需求中 `Action` 默认不绑定状态的意图。
### 1.2 架构总览
```
┌──────────────────── 前端 (React + antd-mobile,打包进 Tauri / OTA) ────────────────────┐
│ App.jsx 路由 │
│ └ /task ──► TaskPage (首屏: 上=RecentProjects 条, 下=当前项目 ProjectView) │
│ /task/list ──► ProjectList (完整项目列表, 从「更多项目」进入) │
│ /task/:pid ──► TaskPage (打开指定项目, 供深链) │
│ ProjectView ──┬── ListView (列表 + 子任务缩进折叠) │
│ ├── BoardView (看板 + 长按拖拽) │
│ └── ProjectManage (项目管理: 成员 / 列设置 / 项目信息) │
│ 组件: TaskCard / SubtaskRow / StatusDot / ExtrasCard / UserPicker / RecentProjects │
│ 状态: TaskStore (项目内单例) / ReorderableList / useLongPressDrag │
│ 工具: src/pages/task/date.js (与后端同口径的自然日边界) │
│ src/pages/task/prefs.js (设备端: 视图记忆 / 上次项目 / 最近项目手动排序) │
└──────────────────────────────┬─────────────────────────────────────────────────────────┘
│ HTTPS + JWT (Authorization: Bearer)
┌──────────────────────────────▼─────────────────────────────────────────────────────────┐
│ Express │
│ /api/projects/* ─► routes/projects.js ─┐ │
│ /api/tasks/* ─► routes/tasks.js ─┤─► middleware.authRequired │
│ /api/columns/* ─► routes/columns.js ─┤─► requireProjectMember (项目成员校验) │
│ └─► task-domain/ ← 【领域逻辑唯一来源】 │
│ resolveStatus() 列/状态联动裁决 │
│ resolveColumn() 自动归位 │
│ retentionWindow() 保留窗口 │
│ copyTask() 跨项目拷贝 │
└──────────────────────────────┬─────────────────────────────────────────────────────────┘
▼
MariaDB: Project / ProjectMember / BoardColumn / Task / TaskMember / TaskTag
```
### 1.3 分层与职责
| 层 | 位置 | 职责 |
| --- | --- | --- |
| 页面 | `src/pages/task/*.jsx` | 视图编排、本地状态、乐观更新与回滚 |
| 组件 | `src/components/task/*.jsx` | 纯展示 + 局部交互(卡片、列、子任务行、状态点、扩展卡片) |
| 设备端偏好 | `src/pages/task/prefs.js` | `viewMode` / `lastProjectId`(**仅设备端**,T-VW-06、T-VW-07) |
| 日期工具 | `src/pages/task/date.js` | 自然日边界计算,**与后端 `task-domain/date.js` 同口径,改一处必须同步另一处** |
| API 封装 | `src/api/task.js` | 统一走既有 `api()`(自带 token / 错误解析) |
| 路由 | `server/routes/tasks.js`、`projects.js`、`columns.js` | 参数校验、鉴权、组装展示态 |
| 领域逻辑 | `server/task-domain/*.js` | **列-状态裁决、自动归位、保留窗口、拷贝**(唯一口径) |
| 持久化 | Prisma Client | 表操作 |
> **与打卡模块的区别**:打卡的周期口径只有 `period.js` 一个文件;任务模块的领域规则(三条)彼此有依赖(归位依赖列序、保留依赖完成时间与列配置),统一收敛到 `task-domain/` 目录,**前后端不得各自实现一遍裁决逻辑**——前端只做乐观更新,最终以服务端返回为准。
### 1.4 目录结构(本次新增/修改)
```
prisma/
schema.prisma # +6 个 model,User 加反向关系
migrations/20261024xxxxxx_add_task_module/ # 新建表 + 外键 + 索引
server/
task-domain/
date.js # 自然日边界(今天/本周/本月)
resolveStatus.js # 列→状态、状态→列、自动归位
retention.js # 列保留窗口 → 可见性
copyTask.js # 跨项目深拷贝
routes/
projects.js # /api/projects/*
columns.js # /api/columns/*
tasks.js # /api/tasks/*
index.js # 挂载三个路由
src/
api/task.js # projectApi / columnApi / taskApi
pages/task/
date.js # 与后端同口径
prefs.js # 设备端偏好(视图记忆/上次项目/最近项目手动排序)
TaskPage.jsx # 首屏容器: RecentProjects 条 + 当前项目 ProjectView
ProjectList.jsx # 完整项目列表(/task/list, 从「更多项目」进入)
ProjectView.jsx # 列表/看板容器
ListView.jsx # 列表视图
BoardView.jsx # 看板视图
ColumnSettings.jsx # 列管理 + 绑定 + 保留选项(由项目管理进入)
TaskDetail.jsx # 任务详情(含扩展卡片 / 子任务;底部安全距离)
ProjectMembers.jsx # 成员管理(由项目管理进入)
ProjectManage.jsx # 项目管理: 成员 / 列设置 / 项目信息 三区
components/task/
TaskCard.jsx SubtaskRow.jsx StatusDot.jsx
ExtrasCard.jsx UserPicker.jsx useLongPressDrag.js
RecentProjects.jsx # 首屏最近项目条(卡片可长按排序)
components/BottomNav.jsx # Tab 增「任务」,位于打卡之后
components/TabIcon.jsx # 增 task 图标
App.jsx # +/task、/task/:pid
styles/globals.css # .task-* 样式段
docs/V0.2/任务模块/ # 需求 / 框架 / 功能 / 数据库 / 界面
```
### 1.5 数据流总览
```
用户操作 ──► 前端乐观更新(本地 store 立即变)
│
▼
PUT /api/tasks/:id { columnId?, status?, done? }
│
▼
requireProjectMember(非成员 403)
│
▼
resolveStatus() ── 读项目 columnBindings(列序 + 绑定)
│ ① 目标列绑定状态 ⇒ status = boundStatus
│ ② 目标列不绑定 ⇒ 保留原 status
│ ③ 状态变了且当前列不绑定该状态 ⇒ 自动归位(列序首个匹配)
│ ④ 无匹配列 ⇒ 列不动
▼
写库(status + columnId 同时落库)
│
▼
返回最新任务 ──► 前端以响应覆盖本地乐观值(不一致则纠正)
```
---
## 2. 领域规则设计(重点)
> 本模块所有"难"的地方都集中在**列与状态的双向联动**上。下面把规则收敛为一组**无歧义的判定函数**,前后端都以此为准。
### 2.1 两个正交维度的定义
| 维度 | 字段 | 取值 | 谁决定 |
| --- | --- | --- | --- |
| **空间** | `Task.columnId` | 指向项目内某个 `BoardColumn` | 用户拖拽 / 自动归位 |
| **属性** | `Task.status` | `todo`(未开始)/ `doing`(执行中)/ `done`(完成) | 用户勾选 / 列绑定联动 |
**二者独立落库**,不做互相推导(见《数据库设计文档》§2)。原因:
1. 未绑定的列(如默认 `Action`)要求二者**完全正交**,任何推导都会丢失信息;
2. 同一状态可被多列绑定(T-BD-03),状态无法反推唯一列;
3. 保留选项依赖"完成时间"而非列,推导会引入隐式状态。
### 2.2 三种联动场景与判定函数
设项目列列表为 `C = [c₁, c₂, …, cₙ]`(按 `sortOrder` 升序),每列 `c.boundStatus ∈ {todo, doing, done, null}`。
**场景 A · 拖拽换列(`T-BD-04/07/08`)**
```
function onColumnChange(task, targetColumnId):
c = columnById(targetColumnId)
if c.boundStatus != null:
task.status = c.boundStatus # 列决定状态
else:
# 目标列不绑定 ⇒ 保留原状态(T-BD-07、T-EX-05)
pass
task.columnId = targetColumnId
```
> **注意**:这里**不存在「从某列拖出即回退状态」的规则**。是否改状态只看**目标列**。这是评审中明确修正过的口径(原设计曾写"拖出回退为未开始",已废弃)。
**场景 B · 改状态(勾选/选择器,`T-BD-14/15/16`)**
```
function onStatusChange(task, newStatus):
task.status = newStatus
# 当前列是否已经"匹配"这个状态?
if task.column.boundStatus == newStatus:
return # 已匹配,列不动
# 不匹配 ⇒ 自动归位,按列序取第一个绑定 newStatus 的列(T-BD-15)
target = first(c in C where c.boundStatus == newStatus)
if target != null:
task.columnId = target.id
else:
# 无匹配列 ⇒ 不移动,保留原列(T-BD-16)
pass
```
**场景 C · 子任务(`T-BD-13`)**
```
if task.parentId != null:
# 子任务完全跳过上述两种联动
persist(status, columnId) as-is
```
### 2.3 判定矩阵(可转写为单测)
以默认配置 `plan→todo, doing→doing, action→null, done→done` 为例:
| # | 初始(列 / 状态) | 操作 | 结果(列 / 状态) | 覆盖需求 |
| --- | --- | --- | --- | --- |
| 1 | plan / todo | 拖到 doing | doing / doing | T-BD-04 |
| 2 | plan / todo | 拖到 action(不绑定) | action / **todo**(保留) | T-BD-07、T-BD-08 |
| 3 | action / todo | 改为 done | **done** / done(自动归位) | T-BD-14、T-BD-15 |
| 4 | action / todo | 改为 doing | doing / doing | T-BD-14 |
| 5 | done / done | 拖到 action | action / **done**(保留) | T-BD-07、T-EX-05 |
| 6 | done / done | 改为 doing | doing / doing | T-BD-14 |
| 7 | plan / todo | 改为 todo | plan / todo(已匹配,不动) | T-BD-14 |
| 8 | 全部列不绑定 done,任务在 action | 改为 done | action / done(**无匹配不移动**) | T-BD-16 |
| 9 | 父任务 done,子任务在 action | 改父任务状态 | 子任务完全不受影响 | T-BD-13 |
| 10 | 两列都绑定 done(done、action) | 改为 done | 列序靠前者(done) | T-BD-06、T-BD-15 |
> 第 8 行是评审中特别确认的:**没有「已完成统一归到 Done 列」的强制规则**,那只是默认配置下的自然效果。用户取消全部绑定后,任务就停在原列。
### 2.4 列配置变更的语义(`T-BD-11`、`T-COL-08`)
**核心原则:配置变更只影响后续操作,不批量改写历史数据。**
| 变更 | 处理 |
| --- | --- |
| 列 A 由「绑定 done」改为「不绑定」 | 已在 A 中且状态为 done 的任务**保持不动**(列与状态都保留),见 T-EX-03 |
| 列 A 绑定状态由 doing 改为 done | 已在 A 中的任务状态**不回写**;后续拖入 A 的任务才按 done 处理(T-BD-11) |
| 删除列 A | 二次确认 → 列内任务迁移到指定列(默认相邻列)→ 迁移后**按目标列重新裁决状态**;A 的绑定解除(T-COL-08、T-EX-16) |
| 新增列 | 取默认值 `retention=forever`、`boundStatus=null`,**不继承相邻列**(B-18) |
> 为什么删除列要"重新裁决"而改绑定不裁决:删除是**数据搬家**(任务必须有个列),搬家后必须与目标列的语义自洽;而改绑定只是改了"未来的规则",不该动已有数据。
### 2.5 空归属与一致性保障
| 风险 | 保障 |
| --- | --- |
| 删除列后任务 `columnId` 悬空 | 迁移在同事务内完成;`Task.columnId` 为 `NOT NULL`,外键 `ON DELETE RESTRICT` 从库层面阻止误删 |
| 列被删后 `boundStatus` 残留 | `BoardColumn` 是实体,列删则绑定自然消失,无残留(优于早期 JSON 方案) |
| 并发拖拽同一任务 | 服务端以「读-判定-写」在同一事务内完成;前端防抖 + 版本号(可选) |
| 状态改了但归位列不存在 | 判定函数显式处理「无匹配列」分支(T-BD-16),不抛异常 |
---
## 3. 重点流程设计
### 3.1 看板拖拽(长按 → 跨列 → 落到子任务)
这是本模块交互最复杂的一条链路,同时涉及**换列**与**降级为子任务**两种语义。
```
长按 300ms ─► 卡片抬起(缩小 + 浮层跟随手指)
│
├─ 拖到【另一列空白区】
│ └─ 松手 → onColumnChange(任务, 目标列)
│ → 按 §2.2 场景 A 裁决
│ → 乐观更新本地列与状态 → PATCH(失败回滚 + Toast)
│
├─ 拖到【另一列某卡片上】
│ └─ 该卡片高亮为"落点"
│ 松手 → 若落点是顶层任务且被拖任务无子任务 ⇒ 降为子任务
│ ├─ 是子任务 ⇒ 拒绝(T-ST-13)
│ ├─ 被拖任务有子任务 ⇒ 拒绝(T-ST-14)
│ └─ 拖到自己/后代 ⇒ 拒绝(T-EX-15)
│
└─ 拖到【原列】→ 视为排序(本期仅更新 sortOrder)
```
**难点与对策**:
| 难点 | 对策 |
| --- | --- |
| 移动端长按与滚动冲突 | 长按 300ms 后才进入拖拽态;进入后 `touch-action:none` 并锁定滚动容器 |
| 看板横向滚动 vs 拖拽换列 | 拖拽时容器允许横向自动滚动(边缘 24px 触发),松手位置决定列 |
| 落点判定(列 vs 卡片) | 用 `elementFromPoint` 命中 `[data-column-id]` / `[data-task-id]`,卡片优先 |
| 误操作 | 拒绝场景统一 Toast 说明原因;松手动画回弹原位 |
| 降为子任务后卡片消失 | 子任务不出独立卡片(T-ST-10),故从其原列移除,父任务卡片内出现该子任务行(T-ST-06) |
> 兜底:无法拖拽时(如长按被系统手势占用),卡片「⋯」菜单提供「移动到列 / 移动到子任务」的等价操作(T-UI-07)。
### 3.2 保留选项的可见性计算与"当场消失"
**窗口计算**(后端 `task-domain/retention.js`,自然日口径):
```
function retentionStart(option, now):
today = startOfDay(now) # Asia/Shanghai 00:00
switch option:
never => 返回"不可见"
forever => 返回"无下界"(全可见)
today => today
3d => today - 2 天 # 含今天共 3 天
week => 本周一 00:00
month => 本月 1 日 00:00
可见性判定:task.status == done
? (doneAt != null && doneAt >= retentionStart(该任务所属列的 option))
: true # 未完成任务始终可见(T-FL-12)
```
**判定依据**:`Task.doneAt`(完成时刻),**与任务当前所在列无关**(T-FL-10);归属哪一列决定用哪一列的选项(T-FL-11)。
**前端"当场消失"**(T-EX-18):
```
每分钟(以及跨日/跨周/跨月边界整点)执行一次:
重新计算当前可见集合
对"由可见变不可见"的任务卡片:
加 .is-leaving(透明 + 高度收缩,约 240ms)
动画结束移除 DOM
其余卡片通过布局动画平滑重排(不做全局刷新)
```
> 也允许退化为"刷新时计算"——需求明确接受该实现方式(§5 决策 23)。跨周期边界用**定时器 + 页面可见性变化(`visibilitychange`)**双保险。
### 3.3 跨项目拷贝
拷贝是**深拷贝**,且要处理目标项目成员差异(T-CP-07)与绑定差异(T-CP-09)。
```
POST /api/tasks/:id/copy { projectId: 目标项目 }
│
├─ 校验:源任务可读、目标是本人成员(T-CP-06)
├─ 事务内:
│ newTask = create({
│ ...全部业务字段(标题/描述/状态/列/扩展卡片/标签)
│ creatorId = 当前操作者 ← T-CP-04
│ projectId = 目标项目
│ columnId = 目标项目中"列名相同"的列,找不到则取第一列
│ })
│ assigneeId = 目标项目成员?保留 : 置 null ← T-CP-07
│ taskMembers = 过滤出目标项目成员中的交集 ← T-CP-07
│ for 每个子任务:同法 create(parentId = newTask.id) ← T-CP-03、T-CP-08
│ 状态与列不强行纠正(T-EX-14)
└─ 返回 { task, dropped: { assignee, members[] } } ← 提示被丢弃的人员
```
**新旧任务完全独立**(T-CP-05):拷贝后无任何关联字段,是两条独立记录。
### 3.4 个人默认项目的创建与保护
```
创建时机:用户首次访问 /api/projects(或注册时)→ 不存在 isPersonal 项目则创建
name = `${user.name}的任务`(T-PJ-04,可改名)
保护规则(服务端强制,前端不提供入口):
DELETE /api/projects/:id → isPersonal ⇒ 400(T-PJ-02、T-EX-09)
POST /api/projects/:id/members → isPersonal ⇒ 400(T-PJ-03、T-EX-09)
PUT /api/projects/:id → 允许改名(T-PJ-04)
```
### 3.5 成员清空后的项目
删除最后一名成员时**不删除项目**,只删 `ProjectMember` 记录(B-11、T-PJ-08):
- 项目成为"无成员项目",任何人都不可见(T-PJ-10);
- 前端不做特判、不报错(T-EX-11);
- 数据保留,由后台维护流程后续处理(不在本模块范围,§1.5)。
> 副作用:曾为该项目任务的负责人 / 成员会显示为「已退出」(T-EX-06),因为其 `userId` 已不是项目成员。
---
## 4. 状态管理与性能
### 4.1 前端状态
| 状态 | 存放 | 生命周期 |
| --- | --- | --- |
| 当前项目、任务列表、列配置 | `TaskStore`(项目内单例) | 进入项目时加载,切换项目时替换 |
| 视图(list/board) | **设备端** `localStorage.iboard_task_prefs` | 按 `projectId` 分键(T-VW-03/06) |
| 上次访问项目 | **设备端** 同上 | 单个 key(T-VW-07) |
| 折叠状态(子任务) | 内存(组件内) | 会话内,不持久化 |
| 弹窗 / 拖拽态 | 组件内 | 操作结束即清 |
### 4.2 性能设计
| 场景 | 目标 | 手段 |
| --- | --- | --- |
| 首屏加载(T-NF-01) | < 1.5s | 任务与列配置**一次请求返回**;子任务随父任务同批返回 |
| 交互响应(T-NF-01) | < 300ms | 乐观更新,不等接口返回 |
| 千级任务(T-NF-05) | 可用 | 索引覆盖(`projectId+columnId`、`projectId+status`);列表虚拟化(按需) |
| 保留计算 | 不额外请求 | 前端本地按 `doneAt` + 列选项实时过滤,切换档位零请求 |
| 看板拖拽 | 60fps | 拖拽期只用 transform,不改布局;落下后才重排 |
### 4.3 一致性与容错
- **乐观更新 + 回滚**:失败时恢复快照并 Toast(T-EX-02)。
- **幂等**:拖拽/勾选 500ms 内重复提交合并(T-EX-01)。
- **配置变更即时生效**:保存列配置后服务端返回最新列列表,前端整体替换并重算可见集合,无需刷新(T-BD-12)。
---
## 5. 风险与权衡
| # | 风险 / 权衡 | 说明与对策 |
| --- | --- | --- |
| 1 | **列与状态双维度带来的认知成本** | 默认配置下二者一一对应(除 Action),用户"不懂也不会错";只有主动改配置时才需要理解绑定 |
| 2 | **自动归位可能让用户"找不到"任务** | 任务从当前列跳走。对策:归位后该卡片带一次高亮动画;提供 Toast「已移至 <列名>」 |
| 3 | **保留选项与"未绑定完成的已完任务"** | 状态为 done 但所有列都不绑定 done 时,任务停在不绑定列,其可见性按该列选项判定(T-FL-11)——语义自洽但需要文案说明 |
| 4 | **列可增删导致看板横向过宽** | 上限暂定 10 列(T-COL-06,**待确认**);超出时看板横向滚动 + 列头悬浮 |
| 5 | **子任务状态与父任务不一致的展示** | 卡片内嵌子任务带**小状态标记**(T-ST-09),避免"父已完成但子未做"的信息丢失 |
| 6 | **跨项目拷贝丢弃人员** | 返回被丢弃的名单并在前端提示,避免静默丢数据(T-EX-08) |
| 7 | **无成员项目的数据残留** | 本期不处理(T-PJ-08),需后台维护;在数据库设计文档中标注为"孤儿项目" |
| 8 | **前后端日期口径不一致** | `date.js` 双份实现是既有项目的做法;本期继续沿用并在两处加注释互指,写单测覆盖边界(周日 23:59 / 周一 00:00 / 月末) |
---
## 6. 测试与验证要点
| 类别 | 要点 |
| --- | --- |
| **领域规则单测** | §2.3 判定矩阵 10 条全覆盖;`retentionStart()` 六档边界(含周日/月末跨点) |
| **接口测试** | 非项目成员访问 403;个人默认项目的两个保护接口返回 400;拷贝后源任务不变 |
| **交互验证** | 拖拽换列 / 降为子任务 / 拒绝场景 Toast;保留档位切换任务当场消失并重排 |
| **兼容性** | Android WebView + 移动浏览器(T-NF-02);375 / 390 / 430 三档视口 |
| **两视图一致** | 列表与看板可见条数一致(T-FL-11、T-FL-14);任一处修改另一处即时可见(T-UI-03) |
---
## 7. 重点设计(2026-10-24 二轮评审)
### 7.1 任务拖动方案(重做)
旧版(手指跟随浮层 + 落点命中即转子任务)在真机上不可靠:浮层遮挡视线、touch 事件与滚动冲突、拖到卡片上误触转子任务。重做后的方案收敛为「**一个可靠手势 + 一个可靠菜单**」:
| 场景 | 交互 | 实现 |
| --- | --- | --- |
| 看板换列 | 长按 400ms 激活(激活前移动 >10px 视为滚动,自动取消)→ 卡片**原位抬起**(不跟随手指、无浮层、无任务名)→ 指针划过列高亮 → 松手落列 | `useColumnDrag`:Pointer Events + `setPointerCapture`(触摸/鼠标统一、捕获后不丢事件、天然吞掉 pointercancel);`elementFromPoint` 命中 `[data-column-id]`;指针靠近容器边缘时 rAF 自动横滚,可拖到屏幕外的列 |
| 列表移动 | 长按 500ms → 弹「移动到列」ActionSheet(当前列打勾) | `TaskRow` 长按计时 → `ProjectView.openMoveMenu`;列表是纵向滚动流,不做自由拖动 |
| 转为子任务 | 不走拖拽(已移除「拖到卡片上」路径,误触率高) | 详情页「层级」区:选父任务转为子任务 / 一键转为顶层 |
关键细节:卡片 `touch-action: pan-y`(纵向滚动留给浏览器,水平位移给拖动);激活与提交都以同一元素的 pointer 事件为准;`pointercancel` 与 `pointerup` 同样清理状态。换列后的状态仍由服务端按目标列绑定统一裁决(§2.2 不变)。
### 7.2 键盘遮挡统一方案
多个底部弹层(任务详情、信息块编辑、人员选择、拷贝)都出现过"输入框被输入法遮挡"。逐个组件接键盘逻辑不可维护,统一为**一个全局信号 + 一个 CSS 类**:
1. **信号源**:`App` 级唯一监听器 `useKeyboardInsetWatcher`,同时监听 `visualViewport` 的 resize/scroll 与 `window.resize`(配合 `index.html` 的 `interactive-widget=resizes-content`,Chromium 系 WebView 键盘弹出会整体收缩布局视口);以"见过的最大视口高"为基线计算键盘插入量,写入 CSS 变量 **`--kb-inset`**(>90px 才视为键盘,过滤地址栏抖动)。
2. **消费端**:底部弹层统一挂 **`.kb-safe`** 类 —— `padding-bottom: calc(安全区 + 20px + var(--kb-inset))`、`max-height` 用 `dvh` 随视口收缩并内部滚动。组件不再各自 import 键盘 hook、不再写内联 padding。
3. **兜底**:长输入区(子任务输入、信息块 textarea)聚焦 300ms 后 `scrollIntoView({block:'center'})`,双保险。
新增弹层只要挂 `.kb-safe` 即自动获得键盘避让,无需再处理。
@@ -0,0 +1,585 @@
# iBoard V0.2 任务模块 需求文档 — 轻量任务 / 项目管理
> 版本:V0.2 · 模块:任务(Task) · 日期:2026-10-24
> 适用范围:iBoard APP 内的任务模块(沿用现有项目框架、路由体系与登录态实现)
> 关联文档:《设计文档》《数据库设计文档》《界面设计》——接口清单在**设计阶段**重新设计,本文档不含接口定义
---
## 1. 总体需求
### 1.1 一句话描述
在 iBoard 现有 APP 内新增一个**轻量级的项目与任务管理模块**:界面交互参考 Todoist,以**项目**为隔离单位组织任务,提供**列表 / 看板**双视图,并允许**每个项目独立配置看板列与任务状态的对应关系、以及每列已完成任务的保留选项**,用最小认知成本覆盖「个人琐事记录」与「多人协作推进」两类场景。
**「轻量」的含义**:不做需求管理、工时、甘特图、工作流引擎、审批流;字段克制、结构最多两层(任务 + 一级子任务)、权限不做细分角色。目标是用最少的配置让用户"打开就能记、记完就能推进"。
### 1.2 核心概念
| 概念 | 定义 |
| --- | --- |
| **项目(Project)** | 任务归属的隔离单位,**也是权限单位**。用户只能看到自己是成员的项目;项目内成员权限目前均等 |
| **个人默认项目(Personal Project)** | 每个用户自动拥有且仅有一个的私有项目。**不可删除、不可添加其他成员**,名称以用户名开头且可自定义,用于个人琐碎事务 |
| **普通协作项目(Shared Project)** | 用户新建的项目,可增删成员、可归档 |
| **项目成员(Member)** | 用户与项目的多对多关联。成员的**存在**决定权限,「负责人 / 任务成员」字段只决定**展示** |
| **任务(Task)** | 一条事务记录。归属唯一项目;含基础字段 + 内置可折叠的**扩展信息卡片**;特例:子任务 |
| **一级子任务(Subtask)** | 挂在父任务下的任务。**最多一层**,不可再嵌套;必须与父任务同项目;完成状态与父任务**相互独立** |
| **负责人(Assignee)** | 任务上的**单人**标记,字号更大、视觉突出。**新建任务时默认填入创建者本人**,可清空 |
| **任务成员(Task Members)** | 任务上的**多人**标记,字号更小、弱化展示,位于卡片 / 条目下方 |
| **扩展信息卡片** | 任务内置的可折叠信息区,承载**任意名称 / 值 / 链接**的扩展信息(来源不限、字段不固定),**默认收起、全部非必填** |
| **看板列(Column)** | 项目级可配置的列,**默认 4 列** `Plan → Doing → Action → Done`;**可增删改、可调整顺序**,数量不固定 |
| **任务状态(Status)** | 任务的处理阶段,取值为 `未开始` / `执行中` / `完成`。**与看板列是两个独立概念**,二者的对应关系由项目配置决定 |
| **列-状态绑定(Column Status Binding)** | 项目级配置:**每一列独立**选择"是否绑定任务状态"以及"绑定到哪个状态"。绑定后列与状态双向联动;不绑定时二者完全独立 |
| **已完成但未绑定状态** | 任务状态为「完成」、但其所在看板列未绑定到「完成」的情形——此时列保持不变 |
| **看板列保留选项(Column Retention)** | 项目级配置:**每一列独立**设置已完成任务的保留时长,取值为「永不显示」/「当天」/「3 天」/「本周」/「本月」/「永久显示」六档,用于控制该列中已完成任务的可见性 |
| **视图记忆** | 设备端按项目独立保存"上次选择的视图(列表 / 看板)",进入项目自动恢复 |
| **上下文** | 用户当前所处的视图位置(某个项目 / 某个父任务的子任务列表 / 某个列表分区),用于解释"本次修改"相对哪些任务生效 |
### 1.3 完整功能
1. **视图体系**:列表视图、看板视图,可随时互相切换,同源数据、实时双向同步;每个项目独立记忆上次使用的视图。列表只是不按列分组,**任务归属的看板列与看板视图完全一致**。**首屏**上部为「最近项目」(3 个卡片 + 更多入口,卡片可手动排序),下部直接展示**当前项目**的任务管理视图,不必先进项目列表。
2. **看板与任务状态的绑定配置**:
- 看板列**默认 4 列** `Plan → Doing → Action → Done`;列**可增删改、可调整顺序**,数量不固定。
- **每一列独立**选择是否绑定任务状态,并可选择绑定到 `未开始 / 执行中 / 完成` 中的任一个;**允许多列绑定同一状态**。
- **列决定状态**:任务拖入某列时,该列绑定了状态则状态随之改变;该列未绑定则**保留原状态**。
- **状态与列不匹配时自动归位**:状态改变后若当前所在列未绑定该状态,则按**当前列顺序**移入第一个绑定该状态的列;**没有匹配列就不移动**,保留当前位置。
- **默认**:`Plan` 绑定「未开始」、`Doing` 绑定「执行中」、`Done` 绑定「完成」、`Action` **不绑定**。
- 配置是**项目级**的,项目内任意成员均可修改,不同项目互不影响。
- **子任务不受列-状态绑定约束**,其状态可与父任务不一致。
3. **已完成任务显示控制**:**按看板列独立配置保留选项**——`永不显示` / `当天` / `3 天` / `本周` / `本月` / `永久显示` 六选一;超出保留选项的已完成任务在该列中自动隐藏。列表视图按其所属列的配置执行同样的显示控制。
4. **任务字段体系**:基础字段(标题必填,负责人默认本人)+ 内置可折叠**扩展信息卡片**(默认收起、全非必填);负责人(单人)与任务成员(多人)均为信息标记。
5. **负责人 & 成员展示规则**:负责人大字号突出、成员小字号弱化并置于下方;二者只能从**当前项目成员**中选择;不改变项目统一读写权限。
6. **子任务体系**:仅一级、必须同项目、父子完成状态互不联动;列表视图支持缩进与折叠展开;**看板卡片内以简洁形式嵌套展示子任务并可直接勾选**,完整详情在卡片弹窗内编辑;支持**长按拖拽把任务降为另一个任务的一级子任务**。
7. **项目体系**:个人默认项目(不可删、不可加成员、名称以用户名开头且可改名);普通协作项目(可增删成员);创建人可主动退出;成员清空后静默保留,交后台维护。成员管理、列设置与项目信息(改名 / 归档 / 删除 / 退出)统一收进「**项目管理**」入口。
8. **项目权限**:以项目为隔离单位,只能看到自己是成员的项目;项目内成员权限均等、全读写;预留权限分级入口但不实现细分角色;任意成员均可归档项目。
9. **跨项目拷贝**:是**拷贝**不是移动;完整复制全部字段、扩展信息、子任务、负责人、成员、状态、看板列、标签;新任务创建人为执行拷贝者;拷贝后新旧任务完全独立。
10. **UI 交互规范**:参考 Todoist 的列表交互与子任务折叠风格;看板卡片轻量化;列表与看板同源、切换无刷新、数据一致;具备空状态 / 加载状态 / 数据异常状态。
11. **模块入口**:任务模块作为**顶级 Tab** 接入底部导航,位于**「打卡」之后**(顺序:打卡 → 任务 → 宏观 → 监控 → 我)。
### 1.4 典型用法
**场景 A · 个人琐事(个人默认项目)**
用户打开模块 → 进入自己的个人默认项目(如「张三的琐事」)→ 列表视图输入标题回车新增,**负责人自动是本人**,也可清空 → 需要推进时切到看板,把任务从 `Plan` 拖到 `Doing`。
因个人项目**不能加成员**,负责人 / 成员的可选范围只有自己,无需任何权限配置。
**场景 B · 多人协作与列-状态绑定**
用户新建普通项目「APP 改版」并加入 3 名同事。默认配置下:任务在 `Plan`=未开始、`Doing`=执行中、`Done`=完成;`Action` 不绑定状态,任务放进去后状态保持不变。
成员 A 把任务拖入 `Done` ⇒ **状态自动变为「完成」**;成员 B 在列表视图把某任务状态改为「完成」⇒ 任务**自动出现在 `Done` 列**。
若任务停留在 `Action`(不绑定)里被改成「完成」,因当前列与状态不匹配,系统**自动归位**到第一个绑定「完成」的列(默认即 `Done`);若该项目把「完成」的绑定全部取消,则**无匹配列,任务留在 `Action` 不动**。若把 `Action` 也设为绑定「完成」,则按列序仍先进 `Done`——这是**项目级**配置,不影响其他项目。
**场景 C · 扩展信息卡片**
新增任务「登录接口返回 500」→ 展开默认收起的**扩展信息卡片** → 按需添加任意条目,如「仓库 = gitlab/app」「分支 = fix/login-500」或一条相关链接(全部非必填,不填不影响任务本身)。
**场景 D · 跨项目复用**
把「APP 改版」里的某任务跨项目拷贝到「APP 二期」→ 生成一条**全新任务**,字段、扩展信息、子任务、负责人、成员、状态、看板列、标签全部复制,创建人记为执行拷贝的用户;此后两任务互不影响。
**场景 E · 按列控制已完成任务显示**
某项目的 `Done` 列已完成任务过多影响阅读 → 把该列保留选项设为「当天」→ 更早的已完成任务自动隐藏;同时把 `Action` 列设为「永不显示」,该列不再展示已完成任务;`Plan` 列保持「永久显示」不受影响。列表视图同样按各任务所属列的配置隐藏它们,与看板一致。切到另一个项目时,该项目各列配置保持自己的设置(互不干扰)。
**场景 F · 拖拽调整层级**
列表视图中长按任务卡片,向上拖动到另一个任务上并停留 → 该任务成为目标任务的**一级子任务**(缩进显示)。若目标任务本身已是子任务,或被拖任务已带子任务,则拒绝并给出提示(仅支持一级)。降为子任务后,它原有的看板列与状态保持不变。
**场景 G · 看板卡片内的子任务**
看板中某任务卡片下方以简洁样式嵌套显示其子任务 → 直接在卡片内勾选子任务完成,**父任务状态不受影响**,子任务状态也不受该列绑定规则约束;每个嵌套子任务带**自身状态小标记**,便于识别与父任务不一致的状态;需要编辑子任务标题或负责人时,点开卡片弹窗操作。
**场景 H · 从底部 Tab 进入(首屏)**
底部导航顺序为「打卡 → **任务** → 宏观 → 监控 → 我」→ 点击「任务」进入首屏:**上部**是最近使用的 3 个项目卡片 + 「更多项目」入口(点卡片即切换当前项目,长按卡片可调整顺序);**下部**直接是当前项目(上次访问的,记录在设备端;无记录则回到个人默认项目)的任务管理页,无需先进项目列表。点「更多项目」才进入完整的项目列表页。
**场景 I · 项目管理**
在项目页头部点「项目管理」→ 弹出统一管理页,内含三个分区:**成员**(查看 / 添加 / 移除)、**列设置**(列管理 / 状态绑定 / 保留选项)、**项目信息**(改名 / 归档 / 删除 / 退出,个人默认项目不出现删除与退出)。
### 1.5 不在本期范围(明确排除)
- 多层子任务(仅一级)。
- 角色 / 权限分级(只预留入口,本期全部成员权限均等)。
- 任务评论、@提及、附件上传、提醒通知、日历同步。
- 列内拖拽排序的精细规则(本期为简单位置值)。
- 甘特图、工时、报表统计。
- 项目成员清空后的数据清理(由后台维护,前端不做特殊处理)。
- 独立的 WebView / 登录态 / 返回键适配(**沿用现有项目框架**,不单独提出要求)。
---
## 2. 术语与概念定义
| 术语 | 定义 |
| --- | --- |
| **项目(Project)** | 任务与权限的隔离单位;用户只能看到自己为成员的项目 |
| **个人默认项目** | 每个用户唯一、自动创建、不可删除、不可添加成员的私有项目;`isPersonal = true` |
| **普通协作项目** | 可增删成员、可归档的项目;`isPersonal = false` |
| **项目成员(Member)** | `ProjectMember` 记录,决定**读写权限** |
| **负责人(Assignee)** | 任务上的单人标记(须为项目成员),决定列表 / 卡片视觉上的突出位置;新建时默认为创建者,可清空 |
| **任务成员(TaskMember)** | 任务上的多成员标记(须为项目成员),弱化展示,可为空 |
| **任务状态(status)** | `todo`(未开始)/ `doing`(执行中)/ `done`(完成),三值枚举 |
| **看板列(column)** | 项目级可配置的列实体,默认 `Plan` / `Doing` / `Action` / `Done` 四列;可增删改、可排序,数量不固定 |
| **列-状态绑定(columnBindings)** | 项目级配置:每列独立声明「是否绑定」及「绑定到哪个状态」;默认 plan→todo、doing→doing、done→done、action→不绑定 |
| **已完成但未绑定状态** | 状态为 `done` 但所在列未绑定到 `done`;此时拖拽/勾选只改状态不改列(见 T-EX-04) |
| **看板列保留选项(columnRetention)** | 项目级配置:每列独立设置已完成任务的保留时长,`never` 永不显示 / `today` 当天 / `3d` 3 天 / `week` 本周 / `month` 本月 / `forever` 永久显示 |
| **视图记忆(viewMode)** | `list` / `board`,**按项目独立记忆、保存在设备端** |
| **扩展信息卡片** | 任务的折叠信息区,存任意 `[{label, value, url?}]` 条目,默认收起、全部非必填 |
| **一级子任务** | `parentId` 非空的任务;其父任务必须为顶层任务,且同项目 |
| **上下文** | 本次操作所处位置(项目 / 父任务 / 列表分区),用于界定"从该列拖出"等相对语义的作用范围 |
| **拷贝(Copy)** | 跨项目生成新任务并复制全部内容;原任务不受影响 |
---
## 3. 需求点
**编号规则**:`T-模块-序号`。前缀 `T` 表示本模块(Task / 任务)。模块:
| 模块码 | 模块名 | 对应 §1.3 条目 |
| --- | --- | --- |
| `VW` | 视图体系 | 第 1 条 |
| `COL` | 看板列管理 | 第 2 条 |
| `BD` | 看板与列-状态绑定 | 第 2 条 |
| `FL` | 已完成任务显示控制(按列保留选项) | 第 3 条 |
| `TK` | 任务字段体系(含扩展信息卡片) | 第 4 条 |
| `AS` | 负责人与成员 | 第 5 条 |
| `ST` | 子任务体系 | 第 6 条 |
| `PJ` | 项目体系 | 第 7 条 |
| `PM` | 项目权限与模块入口 | 第 8 条、第 11 条 |
| `CP` | 跨项目拷贝 | 第 9 条 |
| `UI` | UI 交互规范 | 第 10 条 |
| `DT` | 数据需求 | — |
| `NF` | 非功能需求 | — |
| `EX` | 异常与边界 | — |
**优先级**:`P0` = 本期必须有;`P1` = 本期应该有;`P2` = 有则更好(可延后)。
**验收要点写法**:统一采用「给什么输入 / 做什么操作 → 期望得到什么可观察结果」,可直接转写为测试用例。
### 3.1 视图体系(T-VW)
| 编号 | 需求描述 | 验收要点 | 优先级 |
| --- | --- | --- | --- |
| T-VW-01 | 项目内提供**列表视图**与**看板视图**两种呈现方式 | 同一项目的同一批任务,两种视图均能完整呈现,条数一致 | P0 |
| T-VW-02 | 可在列表 / 看板之间**互相切换**,切换过程**无整页刷新** | 切换不触发页面重载、不出现白屏,切换后滚动位置或回到顶部行为明确 | P0 |
| T-VW-03 | **视图记忆按单个项目独立保存**:A 项目选列表、B 项目选看板互不影响 | 项目 A 切到看板 → 进入项目 B → B 仍为 B 上次选择的视图;回到 A → 仍为看板 | P0 |
| T-VW-04 | 进入项目时**自动恢复**该项目上次选择的视图 | 退出模块后重新进入某项目,视图与上次退出时一致 | P0 |
| T-VW-05 | 从未选择过视图的新项目,**默认使用列表视图** | 新建项目首次进入为列表视图 | P1 |
| T-VW-06 | 视图记忆保存在**设备端**,不要求跨设备同步;本机跨会话保持 | 杀进程重进某项目,视图仍为上次选择;换设备登录不要求一致 | P0 |
| T-VW-07 | **上次访问的项目**记录在**设备端**;无记录时**一律回到个人默认项目** | 杀进程重进落在上次项目;首次使用或记录失效则进入个人默认项目 | P0 |
| T-VW-08 | 任务**首屏上部**展示**最近使用的 3 个项目**卡片,末尾放「更多项目」入口;点击「更多项目」进入完整项目列表页 | 最近项目按访问时间倒序取前 3;点卡片切换当前项目并高亮;点「更多」进入项目列表 | P0 |
| T-VW-09 | 任务**首屏下部**直接展示**当前选中项目**的任务管理视图(列表 / 看板,随该项目视图记忆);当前项目默认为上次访问的项目,无记录时为个人默认项目 | 进入任务 Tab 即见当前项目的任务,**无需先进项目列表**;切换首屏项目卡片后下部随之切换 | P0 |
| T-VW-10 | 首屏**最近项目卡片支持手动排序**(长按拖动调整顺序),排序结果存**设备端**;未手动排序时按最近使用时间自动排列 | 拖动后顺序持久化,杀进程重进保持;新近访问但未手动排序的项目不出现在固定卡位之外 | P1 |
### 3.2 看板列管理(T-COL)
> 看板列是**项目级可配置实体**:默认 4 列,允许增删改与排序。列与「状态绑定」「保留选项」是三条正交的配置。
| 编号 | 需求描述 | 验收要点 | 优先级 |
| --- | --- | --- | --- |
| T-COL-01 | 新建项目**默认生成 4 列**:`Plan` / `Doing` / `Action` / `Done`,顺序即此 | 新项目看板渲染 4 列且名称、顺序与默认一致 | P0 |
| T-COL-02 | 支持**新增列**(自定义名称),新列默认追加在末尾 | 新增后看板多一列,位于最后一列之后,可正常拖入任务 | P0 |
| T-COL-03 | 支持**重命名列** | 改名后看板、列设置、任务归属处同步显示新名称 | P0 |
| T-COL-04 | 支持**删除列**;删除前需二次确认,并明确其中任务的处理方式 | 按 T-EX-16 约定迁移任务,不产生无归属任务 | P0 |
| T-COL-05 | 支持**调整列顺序**(拖动排序),顺序即看板显示顺序 | 拖动后顺序持久化,重新进入保持新顺序 | P0 |
| T-COL-06 | 列数量**不固定**,约束上下限:**至少 1 列、至多 10 列**(上限待确认) | 可增到多于 4 列;删到仅剩 1 列时禁止再删;达 10 列时禁止新增并提示 | P0 |
| T-COL-07 | 列名需**非空**且有长度上限,同一项目内**不允许重名** | 提交空名 / 重名时拒绝保存并提示 | P0 |
| T-COL-08 | 删除列时,若该列绑定了状态,绑定关系随之解除 | 删除绑定「完成」的列后,该状态不再有该列可归位 | P1 |
| T-COL-09 | 列配置属**项目级**,项目内任意成员可修改,不影响其他项目 | 成员 A 改列名,成员 B 同步可见;其他项目列不受影响 | P0 |
| T-COL-10 | 列表视图不展示列分组,但任务仍归属某一列(列可被增删后依然成立) | 删除/新增列后,列表视图中任务的列归属正确 | P0 |
### 3.3 看板与列-状态绑定(T-BD)
| 编号 | 需求描述 | 验收要点 | 优先级 |
| --- | --- | --- | --- |
| T-BD-01 | 看板以**列**为单位组织任务,列由项目配置(默认 4 列,支持增删改与排序,详见 T-COL) | 看板按项目当前列配置渲染;调整列后任务归属随之正确呈现 | P0 |
| T-BD-02 | **每一列独立**配置是否绑定任务状态;绑定时可选择绑定到 `未开始` / `执行中` / `完成` 中任一个 | 每列都有独立的绑定开关与状态选择器;各列配置独立保存 | P0 |
| T-BD-03 | 允许**多列绑定到同一个状态**(如 `Done` 与 `Action` 都绑定「完成」) | 同一项目配置两列均绑定「完成」后,两列均能触发完成联动 | P0 |
| T-BD-04 | **绑定列 → 拖入即改状态**:任务落入绑定列时,任务状态改为该列所绑定的状态 | 把「未开始」任务拖入绑定「执行中」的列 → 状态变「执行中」;拖入绑定「完成」的列 → 状态变「完成」 | P0 |
| T-BD-05 | **状态 → 自动移入列**:任务状态改为某状态时,任务自动移入绑定该状态的列 | 列表视图把状态改为「完成」→ 切到看板,任务位于绑定「完成」的列 | P0 |
| T-BD-06 | 同一状态被多列绑定时,自动移入**按当前列顺序取第一个**匹配列,规则确定且可预期 | 两列均绑定「完成」时,任务状态改为「完成」后仅出现在列序靠前的那个绑定列,不重复出现 | P0 |
| T-BD-07 | **列决定状态,不看来源列**:拖动任务时,一律以「**目标列**的绑定配置」决定任务状态——目标列绑定了状态,则状态改为所绑定状态;**目标列未绑定,则任务保留原状态** | ①从绑定「完成」的列拖到绑定「执行中」的列 → 状态变「执行中」;②从任意列拖到**未绑定**的 `Action` → 状态保持不变 | P0 |
| T-BD-08 | **不绑定列**与任务状态**完全独立**:拖入、拖出该列以及在其他位置改变状态,均不改变该列归属以外的行为 | 把「执行中」任务拖入未绑定的 `Action` → 状态仍为「执行中」,仅列变化 | P0 |
| T-BD-14 | **状态与所在列不匹配时自动归位**:任务状态改变后,若其**当前所在列未绑定该状态**,则自动查找绑定该状态的列并将其移入 | 在未绑定的 `Action` 中把任务改为「完成」→ 任务自动移入绑定「完成」的列 | P0 |
| T-BD-15 | 自动归位时**按当前列的实际显示顺序取第一个匹配列**(列顺序可被用户调整) | 若两列都绑定「完成」,状态改为「完成」后移入排在前面那一列 | P0 |
| T-BD-16 | **无匹配列时任务不移动**,保留在原列(状态照常改变) | 取消所有列对「完成」的绑定后,把任务改为「完成」→ 状态为完成,列保持不变 | P0 |
| T-BD-09 | **默认配置**:`Plan` 绑定「未开始」、`Doing` 绑定「执行中」、`Done` 绑定「完成」、`Action` 不绑定 | 新建项目首次打开看板配置,四列取值与默认完全一致 | P0 |
| T-BD-10 | 绑定配置是**项目级**的,项目内**任意成员**均可修改,不同项目互不影响 | 成员 A 修改项目一配置成功;项目二配置不受影响;成员 B 也能修改 | P0 |
| T-BD-11 | 修改绑定配置**不批量改写**既有任务的状态与列,仅对之后的操作生效 | 把 `Action` 由不绑定改为绑定「完成」后,已在 `Action` 的任务状态保持原样 | P1 |
| T-BD-12 | 绑定配置变更后,看板与列表**即时一致**,无需手动刷新 | 保存配置后,看板列徽标与完成态立即反映新规则 | P1 |
| T-BD-13 | **子任务不受看板列-状态绑定影响**:子任务状态不因所在列而改写,也不触发「状态 → 移入列」 | 父任务在绑定「完成」的列,其子任务状态仍可为「执行中」且不被改写;修改子任务状态不使其离开当前列 | P0 |
### 3.4 已完成任务显示控制(T-FL)
> 本节的「保留选项」**以看板列为单位**配置,取代原先的「项目整体 5 档过滤」。
| 编号 | 需求描述 | 验收要点 | 优先级 |
| --- | --- | --- | --- |
| T-FL-01 | **每一列独立**配置已完成任务的保留选项,配置项属于项目级 | 各列(含新增列)都有独立配置入口,修改一列不影响其他列 | P0 |
| T-FL-02 | 保留选项为**固定 6 档**:`永不显示` / `当天` / `3 天` / `本周` / `本月` / `永久显示` | 下拉仅此六项,不提供自由输入天数 | P0 |
| T-FL-03 | 「永久显示」:该列已完成任务**始终展示**,不受时间限制 | 设为永久显示后,历史任意时间完成的任务均可见 | P0 |
| T-FL-04 | 「永不显示」:该列已完成任务**始终不展示** | 设为永不显示后,该列无已完成任务卡片 | P0 |
| T-FL-05 | 「当天」:仅展示**完成于今天(自然日)**的已完成任务 | 今天完成的可见;昨天完成的次日即不显示 | P0 |
| T-FL-06 | 「3 天」:展示**完成于最近 3 个自然日(含今天)**的已完成任务 | 今天、昨天、前天完成可见,更早不显示 | P0 |
| T-FL-07 | 「本周」:展示**完成于本周(周一 00:00 起,至今天)**的已完成任务 | 周一起算;下周一进来看,上周完成的任务不再显示 | P0 |
| T-FL-08 | 「本月」:展示**完成于本月(当月 1 日起,至今天)**的已完成任务 | 当月 1 日起算;进入次月后,上月完成的任务不再显示 | P0 |
| T-FL-09 | 超出所选档位范围的已完成任务**自动隐藏**(无需手动操作) | 某列设为「当天」,次日进入该列,昨日完成的任务自动消失 | P0 |
| T-FL-10 | 保留判定以「该任务的**完成时间**」为准,与任务当前所在列无关 | 任务完成后再移动列,可见性仍按其完成时间与新列档位判定 | P0 |
| T-FL-11 | **列表视图与看板视图行为一致**:列表只是不按看板列分组展示,任务归属哪一列仍是哪一列,显示控制同样按**其所属列**的配置执行 | 看板中某列已隐藏的已完成任务,在列表视图中同样不出现;两视图可见条数一致 | P0 |
| T-FL-12 | 该配置仅影响**已完成任务**;未完成任务始终展示,不受保留选项影响 | 未完成任务在任何保留选项配置下均可见 | P0 |
| T-FL-13 | 显示控制为**展示层过滤**,不改变任务数据与状态 | 修改保留选项后,任务数据、状态、计数口径不发生变化 | P0 |
| T-FL-14 | 过滤后每列的计数与展示条数一致 | 列头显示 N 条时,该列实际可见卡片同为 N 条 | P1 |
| T-FL-15 | 保留选项配置在**项目内所有成员间共享**(属项目配置,非个人偏好) | 成员 A 修改后,成员 B 进入即按新配置显示 | P0 |
### 3.5 任务字段体系(T-TK)
| 编号 | 需求描述 | 验收要点 | 优先级 |
| --- | --- | --- | --- |
| T-TK-01 | 任务包含**基础字段**:标题(必填)、描述、所属项目、看板列、任务状态、负责人、任务成员、标签、创建人、创建时间、更新时间 | 新建任务后上述字段均可正确保存与读取 | P0 |
| T-TK-02 | 标题为**唯一必填**字段,其余字段均可为空 | 仅填标题即可保存成功;仅清空标题时拒绝保存并提示 | P0 |
| T-TK-03 | 任务状态为三值枚举:`未开始` / `执行中` / `完成` | 任一任务的状态只能取这三个值之一,不存在第四种取值 | P0 |
| T-TK-04 | ~~内置扩展信息卡片~~ **已移除**(2026-10-24 评审):相关信息记录改由**项目信息块**承载(T-PJ-13) | — | 已取消 |
| T-TK-05 | ~~扩展信息条目~~ **随 T-TK-04 一并移除** | — | 已取消 |
| T-TK-06 | ~~扩展信息卡片折叠~~ **随 T-TK-04 一并移除** | — | 已取消 |
| T-TK-07 | ~~扩展信息非必填~~ **随 T-TK-04 一并移除** | — | 已取消 |
| T-TK-08 | 每条任务有**负责人(单人)** | 任务最多只有一个负责人,重复设置时以最后一次为准 | P0 |
| T-TK-09 | 新建任务时**负责人默认填入创建者本人** | 新建任务后未手动修改时,负责人即为创建者 | P0 |
| T-TK-10 | 负责人**允许清空为空** | 清空后任务可正常保存,展示为「未指派」 | P0 |
| T-TK-11 | 每条任务有**任务成员(多人)** | 可设置 0 ~ 多个成员,成员不重复 | P0 |
| T-TK-12 | 任务支持**标签** | 可添加多个标签,展示于条目 / 卡片 | P1 |
| T-TK-13 | 任务的创建人、创建时间、更新时间由系统维护,用户不可直接编辑 | 编辑任务不改变创建人与创建时间;任何修改刷新更新时间 | P1 |
### 3.6 负责人 & 成员 UI 展示规则(T-AS)
| 编号 | 需求描述 | 验收要点 | 优先级 |
| --- | --- | --- | --- |
| T-AS-01 | **负责人**字号更大、视觉突出,位于条目 / 卡片的主要信息区 | 同名负责人与成员并排时,负责人字号明显更大且位于主位 | P0 |
| T-AS-02 | **任务成员**字号更小、弱化展示(低对比色),位于卡片 / 条目**下方** | 成员信息渲染在负责人 / 标题之下,视觉层次弱于负责人 | P0 |
| T-AS-03 | 负责人只能从**当前项目的成员**中选择 | 选择器只列出本项目成员;项目外用户无法被选中 | P0 |
| T-AS-04 | 任务成员只能从**当前项目的成员**中选择 | 选择器只列出本项目成员,不提供项目外用户 | P0 |
| T-AS-05 | 负责人为空(已清空)时展示「未指派」占位,正常渲染 | 清空负责人后条目 / 卡片显示「未指派」,可再次指派 | P0 |
| T-AS-06 | 人员被移出项目后,其负责人 / 成员标记按约定处理且不报错 | 见 T-EX-06 | P1 |
| T-AS-07 | 负责人、任务成员**仅为信息标记**,不改变项目统一读写权限 | 仅为负责人或仅为成员的用户,对项目读写能力与其他成员完全相同 | P0 |
### 3.7 子任务体系(T-ST)
| 编号 | 需求描述 | 验收要点 | 优先级 |
| --- | --- | --- | --- |
| T-ST-01 | **仅支持一级子任务**,不支持多层嵌套 | 子任务上无「添加子任务」入口;后端拒绝以子任务为父任务创建任务 | P0 |
| T-ST-02 | 子任务**必须与父任务归属同一项目** | 尝试将子任务置于其他项目时被拒绝(含跨项目拷贝场景) | P0 |
| T-ST-03 | 父任务与子任务的**完成状态相互独立**、不自动联动 | 子任务全部完成,父任务状态不变;父任务完成,子任务状态不变 | P0 |
| T-ST-04 | **列表视图**支持子任务**缩进**展示 | 子任务在父任务下方缩进渲染,层级一眼可辨 | P0 |
| T-ST-05 | **列表视图**支持子任务**折叠 / 展开** | 折叠后子任务不显示且不占位;展开后恢复;折叠状态可感知 | P0 |
| T-ST-06 | **看板卡片内嵌套展示子任务**:含子任务的卡片中,子任务以**简洁形式嵌套**显示在任务卡片内 | 卡片内可见子任务列表(如紧凑单行:复选框 + 标题),样式轻量、不喧宾夺主 | P0 |
| T-ST-07 | 嵌套子任务可在**卡片内直接勾选完成**;完整详情(编辑标题 / 负责人 / 扩展信息等)在卡片弹窗内查看编辑 | 点击子任务复选框即切换其状态;点击卡片打开弹窗可编辑子任务全部字段 | P0 |
| T-ST-08 | **子任务状态与父任务状态可以不一致**,且子任务**不受看板列-状态绑定约束** | 父任务在 `Done`(绑定「完成」)时,其子任务可为「未开始」且不被改写 | P0 |
| T-ST-09 | 卡片内嵌套的子任务**显示自身状态小标记**(区分「未开始 / 执行中 / 完成」),以便识别与其父任务不一致的状态 | 父任务为完成、子任务为执行中时,卡片内可见子任务带「执行中」小标记 | P0 |
| T-ST-10 | 子任务不可作为看板独立卡片出现,只在其父任务卡片内嵌套 | 看板上一个任务只占一张卡,子任务不会单独成卡 | P1 |
| T-ST-11 | 删除父任务时**级联删除**其子任务 | 删除父任务后子任务一并删除,不产生孤儿数据 | P0 |
| T-ST-12 | **支持长按拖拽把任务降为另一个任务的一级子任务** | 列表视图长按任务 A 拖到任务 B 上释放 → A 成为 B 的子任务并缩进显示 | P0 |
| T-ST-13 | 拖拽成为子任务时,若目标任务**本身已是子任务**,则拒绝操作并提示 | 把任务拖到某个子任务上 → 操作被拒绝,给出「仅支持一级子任务」提示,数据不变 | P0 |
| T-ST-14 | 拖拽成为子任务时,若被拖任务是**有子任务的父任务**,则拒绝并提示(父任务不可整体挂到别的任务下) | 拖动含子任务的任务到其他任务上 → 拒绝,避免形成二级嵌套 | P0 |
| T-ST-15 | 拖拽成为子任务时,子任务的**看板列与状态保持自身原值**,仅改变父子归属 | 按 T-EX-13 约定处理,保证子任务与父任务同项目且层级合法 | P1 |
### 3.8 项目体系(T-PJ)
| 编号 | 需求描述 | 验收要点 | 优先级 |
| --- | --- | --- | --- |
| T-PJ-01 | 每个用户**自动拥有且仅有一个个人默认项目** | 新用户首次进入模块即已存在个人项目;不可再创建第二个 | P0 |
| T-PJ-02 | 个人默认项目**不可删除** | 无删除入口;直接调用删除接口被拒绝 | P0 |
| T-PJ-03 | 个人默认项目**不能添加其他成员** | 成员管理入口不存在或不可用;调用添加成员接口被拒绝 | P0 |
| T-PJ-04 | 个人默认项目名称**以用户名开头**,且**整体名称可自定义修改** | 初始名称以用户名开头;改名后修改内容生效,仍保持「个人项目」性质 | P0 |
| T-PJ-05 | 支持**新建普通协作项目** | 可创建多个普通项目,创建者为成员 | P0 |
| T-PJ-06 | 普通项目**可以增删成员** | 添加 / 移除成员后,成员列表与可见性即时变化 | P0 |
| T-PJ-07 | **项目创建人可以主动退出项目** | 创建人退出后不再是成员,该项目不再对其可见 | P0 |
| T-PJ-08 | 项目**成员清空后静默保留**,交后台后续维护,前端不做特殊处理 | 成员全部退出后,前端不弹特殊提示、不出现错误;不可见 | P0 |
| T-PJ-09 | 项目可**归档**,归档项目与进行中项目区分展示 | 归档后从进行中列表移出,可在归档区找回 | P1 |
| T-PJ-10 | 项目列表按用户可见范围返回,仅含自己为成员的项目 | 非成员项目不出现在列表中 | P0 |
| T-PJ-11 | 项目的**成员管理、成员新增、列设置**统一收进「**项目管理**」入口;项目页头部不再散置多个功能入口 | 项目管理内含 成员 / 列设置 / 项目信息 三个分区;头部仅保留「项目管理」一个入口 | P0 |
| T-PJ-12 | 项目管理内提供**项目信息管理**:改名(T-PJ-04)、归档 / 取消归档(T-PJ-09)、删除项目(T-PJ-02,个人默认项目禁用)、退出项目(T-PJ-07,创建人可退) | 各操作在项目管理中可用且遵循既有约束;个人项目不出现删除与退出入口 | P0 |
| T-PJ-13 | 项目支持**信息块**:可自由添加任意多个长文本信息块(标题 + 多行内容,如 Readme / 项目说明),每块**独立编辑与删除**;属项目级数据,全体成员可见可编辑 | 可增删改任意块;内容支持多行长文本;所有成员看到同一份信息块 | P0 |
### 3.9 项目权限(T-PM)
| 编号 | 需求描述 | 验收要点 | 优先级 |
| --- | --- | --- | --- |
| T-PM-01 | 权限**以项目为隔离单位**:用户只能看到自己是成员的项目及其任务 | 非成员访问某项目接口返回 403 / 404,列表不出现该项目 | P0 |
| T-PM-02 | 项目内**所有成员权限均等**,具备**全读写**能力 | 任意成员均可新增 / 编辑 / 删除任务、管理成员、修改列-状态绑定与保留选项配置 | P0 |
| T-PM-03 | 预留**权限分级入口**,本期**不实现**细分角色 | 数据结构或接口层保留扩展位;界面无角色分配功能 | P1 |
| T-PM-04 | 项目内**所有成员都可以归档项目** | 任意成员执行归档均成功 | P0 |
| T-PM-05 | 复用现有项目框架的登录态与鉴权,未登录访问被拒 | 无有效登录态访问返回 401 | P0 |
| T-PM-06 | 任务模块作为**顶级 Tab** 接入底部导航,位置在**「打卡」之后** | 底部 Tab 顺序为「打卡 → 任务 → 宏观 → 监控 → 我」;点击该 Tab 进入任务模块 | P0 |
| T-PM-07 | 进入任务模块后**默认落在上次访问的项目**(记录在**设备端**);**无记录时一律回到个人默认项目** | 首次进入 / 记录失效 → 个人默认项目;再次进入 → 上次离开时的项目;换设备不继承 | P0 |
### 3.10 跨项目拷贝(T-CP)
| 编号 | 需求描述 | 验收要点 | 优先级 |
| --- | --- | --- | --- |
| T-CP-01 | 跨项目操作为**拷贝**,不是移动:源任务保留在原项目 | 拷贝后源任务仍存在于原项目且内容不变 | P0 |
| T-CP-02 | 拷贝时**完整复制全部字段** | 标题、描述、负责人、成员、标签、任务状态、看板列、扩展信息卡片逐项比对一致 | P0 |
| T-CP-03 | 拷贝时**完整复制子任务** | 子任务条数、标题、状态、列归属均与源任务一致 | P0 |
| T-CP-04 | 拷贝后**新任务的创建人**为本次执行拷贝操作的用户 | 新任务创建人 = 操作者,非源任务创建人 | P0 |
| T-CP-05 | 拷贝完成后**新旧任务互相独立**,后续修改互不影响 | 修改新任务标题 / 状态,源任务不变;反之亦然 | P0 |
| T-CP-06 | 拷贝目标必须是操作者**有权限**的项目 | 目标项目非本人成员时拒绝并提示 | P0 |
| T-CP-07 | 拷贝时负责人 / 成员若不属目标项目成员,按约定处理 | 按 T-EX-08 约定处理,不产生「项目外人」脏数据 | P1 |
| T-CP-08 | 拷贝包含子任务时,子任务与父任务**同属目标项目** | 拷贝后子任务项目归属与父任务一致 | P0 |
| T-CP-09 | 拷贝时若目标任务项目的**列-状态绑定规则不同**,按 T-EX-14 约定处理 | 状态与列不出现互相矛盾的组合 | P1 |
### 3.11 UI 交互规范(T-UI)
| 编号 | 需求描述 | 验收要点 | 优先级 |
| --- | --- | --- | --- |
| T-UI-01 | 整体界面、列表交互、子任务折叠风格**参考 Todoist** | 列表条目结构、快速新增、勾选完成、子任务折叠的交互与 Todoist 同类操作一致 | P0 |
| T-UI-02 | **看板卡片轻量化**,样式简洁 | 卡片仅承载必要信息,无多余装饰;信息层级清晰 | P0 |
| T-UI-03 | 列表与看板**同源数据**,修改**实时双向同步** | 列表改标题 / 状态 → 切到看板立即可见;看板改 → 切到列表立即可见 | P0 |
| T-UI-04 | 视图切换**无刷新**、数据一致 | 切换过程中不发起整页加载,数据不发生丢失或错位 | P0 |
| T-UI-05 | 移动端适配:复用现有样式体系与底部导航,安全区不遮挡内容 | 375~430px 视口无横向滚动、无内容被遮挡 | P0 |
| T-UI-06 | 勾选完成有即时反馈(视觉状态变化),不阻塞后续操作 | 点击勾选后立即变化,无需等待接口返回才能继续操作 | P1 |
| T-UI-07 | 看板拖拽在移动端支持**长按拖拽**,并提供卡片「移动到」快捷项作为兜底 | 长按可拖起卡片;无法拖拽的场景可用「移动到」菜单完成同效果 | P0 |
| T-UI-08 | 具备**空状态**展示(无项目 / 无任务 / 无子任务等) | 各层级无数据时展示空态与引导入口,不出现空白页 | P0 |
| T-UI-09 | 具备**加载状态**展示 | 首屏与切换视图时展示加载态,不出现闪烁或错乱 | P0 |
| T-UI-10 | 具备**数据异常状态**展示,可重试 | 接口失败时展示异常态与重试入口,重试成功后恢复 | P0 |
| T-UI-11 | 弹层(任务详情等)**底部按钮与屏幕底边保持安全距离**,不被安全区 / 手势条遮挡 | 底部留白 ≥ 安全区高度 + 12px;小屏下弹层内容可滚动,删除等底部按钮始终完整可见可点 | P0 |
### 3.12 数据需求(T-DT)
| 编号 | 需求描述 | 验收要点 | 优先级 |
| --- | --- | --- | --- |
| T-DT-01 | 新增 Prisma 模型:`Project`(含 `isPersonal`、列-状态绑定配置、列保留选项配置、`archived` 等)、`ProjectMember` | 字段见附录 A | P0 |
| T-DT-02 | 列-状态绑定配置**按列存储**,每列独立保存「是否绑定 + 绑定状态」,支持多列指向同一状态 | 任意数量列的配置可独立读写;两列绑定同一状态可正确落库 | P0 |
| T-DT-03 | 列保留选项配置**按列存储**,取值覆盖六档:`never` / `today` / `3d` / `week` / `month` / `forever` | 三档取值均可落库并正确读回 | P0 |
| T-DT-04 | 新增 Prisma 模型:`Task`(含 `parentId` 自关联、`assigneeId`、`status`、看板列、扩展信息卡片、`sortOrder`) | 字段见附录 A | P0 |
| T-DT-05 | 新增 Prisma 模型:`TaskMember`(任务成员关联)、扩展信息卡片结构、标签结构 | 字段见附录 A | P0 |
| T-DT-06 | 视图记忆按**设备端**存储,不要求落库 | 换设备登录不要求视图一致 | P0 |
| T-DT-07 | 建立必要索引:任务按项目查询、按父子关系查询、按状态查询 | 列表 / 看板 / 显示控制查询均走索引 | P0 |
| T-DT-08 | 提供迁移 SQL 并纳入 `prisma migrate deploy` 流程 | 与现有迁移流程一致 | P0 |
| T-DT-09 | 归档采用软删除语义,历史数据保留 | 归档不物理删除任务与项目数据 | P1 |
### 3.13 非功能需求(T-NF)
| 编号 | 需求描述 | 验收要点 | 优先级 |
| --- | --- | --- | --- |
| T-NF-01 | 项目内任务列表首屏加载 < 1.5s(正常网络),交互响应 < 300ms | 真机可感知流畅 | P0 |
| T-NF-02 | 兼容当前 APP 的 WebView 运行环境与移动端浏览器 | 真机验证通过 | P0 |
| T-NF-03 | 前端代码组织遵循现有约定:`src/pages/task/*` + `src/components/*` + `server/routes/task*.js` | 结构一致 | P0 |
| T-NF-04 | 路由与接口挂载**沿用现有项目框架**的组织方式 | 与现有模块的组织方式一致 | P0 |
| T-NF-05 | 数据量按「单项目千级任务 / 年」设计,无需分表 | 千级任务下列表与看板可用 | P1 |
### 3.14 异常与边界(T-EX)
| 编号 | 需求描述 | 处理约定 | 优先级 |
| --- | --- | --- | --- |
| T-EX-01 | 重复点击勾选 / 重复拖拽 | 前端防抖 + 后端幂等,状态最终一致 | P0 |
| T-EX-02 | 接口失败 / 断网 | 乐观更新回滚 + 提示,可重试 | P0 |
| T-EX-03 | 绑定配置被改为「不绑定」时,已在该列的已完成任务 | 保持当前列与状态不变,等待用户后续操作 | P0 |
| T-EX-04 | 任务状态与所在列绑定不一致(如在不绑定的 `Action` 中改为「完成」) | 触发自动归位:移入第一个绑定该状态的列;**无匹配列则不移动**,保留原列(见 T-BD-14~16) | P0 |
| T-EX-05 | 任务被拖到**未绑定状态**的列(如 `Action`),其原状态如何处理 | 原状态**保持不变**,不因离开原列而回退(不存在「拖出即回退」规则) | P0 |
| T-EX-06 | 项目成员被移除,其仍担任某任务负责人 / 成员 | 保留历史标记,展示为「已退出」样式;不报错、不清空 | P1 |
| T-EX-07 | 删除父任务 | 级联删除其子任务,确认弹窗提示「将一并删除 N 条子任务」 | P0 |
| T-EX-08 | 跨项目拷贝时负责人 / 成员不属于目标项目 | 仅保留属于目标项目成员者,其余丢弃并在结果中提示 | P1 |
| T-EX-09 | 个人默认项目尝试添加成员 / 尝试删除 | 接口返回明确错误,前端不提供入口 | P0 |
| T-EX-10 | 某列设为「当天」等档位且该列无符合条件的已完成任务 | 展示该列空态,不报错 | P0 |
| T-EX-11 | 项目成员清空后的项目 | 静默保留,前端不报错、不特殊提示 | P0 |
| T-EX-12 | 子任务所属项目与父任务不一致(绕过前端直接调用) | 后端拒绝并返回错误 | P0 |
| T-EX-13 | 拖拽成为子任务时,子任务的**看板列与状态**如何取值 | 保持自身原值不变,仅改变父子归属 | P1 |
| T-EX-14 | 跨项目拷贝时,源任务的状态 / 列在目标项目绑定规则下无对应关系 | 沿用源值落库,不强行纠正;由后续用户操作自然修正 | P1 |
| T-EX-15 | 拖拽将任务放到自身或其后代上 | 拒绝操作并提示,避免形成环形父子关系 | P0 |
| T-EX-16 | 删除仍有任务的列 | 二次确认后,该列任务迁移至**相邻列**(或由用户指定目标列),不产生无归属任务 | P0 |
| T-EX-17 | 删除唯一剩余的列 | **拒绝操作**并提示「至少保留一列」 | P0 |
| T-EX-19 | 新增列时已达列数上限 | 拒绝新增并提示已达上限,不产生空列 | P1 |
| T-EX-18 | 已完成任务跨周期(周一零点 / 月初零点)后应隐藏 | **当场消失**并带移除动画,其余任务平滑重排,不留空白 | P1 |
---
## 4. 优先级汇总(本期交付范围)
- **P0(必须)**:T-VW-01~04、T-VW-06~09;T-COL-01~07、T-COL-09、T-COL-10;T-BD-01~10、13~16;T-FL-01~13、15;T-TK-01~11;T-AS-01~05、07;T-ST-01~09、11~14;T-PJ-01~08、10~13;T-PM-01、02、04~07;T-CP-01~06、08;T-UI-01~05、07~11;T-DT-01~08;T-NF-01~04;T-EX-01~05、07、09~12、15~17。
- **P1(应该)**:T-VW-05、10;T-COL-08;T-BD-11、12;T-FL-14;T-TK-12、13;T-AS-06;T-ST-10、15;T-PJ-09;T-PM-03;T-CP-07、09;T-UI-06;T-DT-09;T-NF-05;T-EX-06、08、13、14、18、19。
---
## 5. 已决策问题(评审记录)
以下问题已在 2026-10-24 评审中确认,作为实现依据:
| # | 问题 | 决策 |
| --- | --- | --- |
| 1 | 列-状态绑定是单列还是多列? | **每列独立配置**,允许**多列绑定同一状态**;默认 plan→未开始、doing→执行中、done→完成、action→不绑定(T-BD-02、03、09) |
| 2 | 保留档位的时间边界按自然日还是滚动小时? | **自然日**(Asia/Shanghai,含今天 00:00 起);本周按周一起算、本月按 1 日起算 |
| 3 | 视图记忆 / 显示配置存在设备端还是服务端? | 视图记忆**设备端**、不跨设备;保留选项**项目级共享**(T-VW-06、T-FL-12、T-DT-06) |
| 4 | 删除父任务时子任务如何处理? | **级联删除**,弹窗提示条数(T-EX-07) |
| 5 | 已完成任务在看板中的呈现? | 由**该列的保留选项**控制显示与隐藏(T-FL-01~10) |
| 6 | 看板拖拽的移动端交互? | **长按拖拽** + 「移动到」兜底;**允许拖拽降为子任务**(T-UI-07、T-ST-12) |
| 7 | 项目成员清空后是否需要本模块的管理入口? | **本期不承担**,交后台;前端静默(T-PJ-08) |
| 8 | 扩展信息组的具体字段清单? | **不固定字段**,改为**任意扩展信息卡片**(T-TK-04、05) |
| 9 | 已完成显示策略的粒度? | 改为**按看板列独立的保留选项**(T-FL-01、02) |
| 10 | 是否单独要求 WebView / 登录态 / 返回键? | **不单列**,沿用现有项目框架(T-NF-04、T-PM-05) |
| 11 | 同一状态被多列绑定,状态变化时进哪一列? | **按当前列顺序取第一个匹配**(T-BD-06、15、B-3) |
| 12 | **从绑定列拖出**时状态是否回退? | **不设「拖出即回退」规则**。只看**目标列**:目标列绑定了状态就按目标列改,未绑定则**保留原状态**(T-BD-07、B-4) |
| 13 | 列表视图如何处理「按列」的显示控制? | 列表只是**不按列分组**,任务归属仍是原看板列,行为与看板**完全一致**(T-FL-11、B-6) |
| 14 | 看板卡片如何展示子任务? | **在卡片内嵌套简洁展示**,可直接勾选;完整详情在卡片弹窗(T-ST-06、07) |
| 15 | 子任务是否受列-状态绑定影响? | **不受**。子任务状态可与父任务不一致,拖入列不改写其状态(T-BD-13、T-ST-08、B-7) |
| 16 | 父任务能否整体挂到别的任务下? | **不能**。含子任务的父任务不可被拖拽为子任务(T-ST-14) |
| 17 | 保留选项的取值范围? | 固定 **6 档**:`永不显示` / `当天` / `3 天` / `本周` / `本月` / `永久显示`,不做自由输入(T-FL-02、B-5) |
| 18 | 任务状态与所在列绑定不一致时怎么处理? | **触发自动归位**:查找绑定该状态的列并移入,按列序取第一个;**无匹配列则不移动**(T-BD-14~16、B-4b) |
| 19 | 卡片内嵌套的子任务是否显示自身状态? | **显示小状态标记**,以区分与父任务不一致的状态(T-ST-09) |
| 20 | 任务模块入口放在哪里? | 作为**顶级 Tab**,位置在**「打卡」之后**(T-PM-06) |
| 21 | 5 个底部 Tab 是否影响既有布局与默认首页? | **暂无障碍**;默认首页**仍为打卡**(T-PM-06) |
| 22 | 任务状态为「完成」但所有列都未绑定「完成」时,列表视图按哪一列过滤? | **保留在当前列**,按该列保留选项过滤。**不存在「已完成统一用 Done 列」的强制规则**,那只是默认配置下的自然效果,用户可完全自定义(T-FL-11) |
| 23 | 「本周 / 本月」跨周跨月时是否要求实时隐藏? | **当场消失并带移除动画,其余任务平滑重排**;以刷新方式实现也可接受(T-EX-18) |
| 24 | 「上次访问的项目」记录在哪?无记录时如何? | 记录在**设备端**;**无记录一律回个人默认项目**(T-VW-07、T-PM-07) |
| 25 | 看板列是否固定 4 列? | **非固定**:默认 4 列,支持增删改与排序,数量可变,至少保留 1 列(T-COL-01~07) |
| 26 | 是否允许删除最后一个列? | **不允许**。删除唯一剩余列时拒绝并提示,项目至少保留 1 列(T-COL-06、T-EX-17) |
| 27 | 新增列的默认设置? | 按**默认值**:保留选项 `永久显示`、不绑定状态,**不继承**相邻列设置(T-COL-02、B-18) |
| 28 | 看板列数量上限? | 暂定**上限 10 列**(`待确认`,见下) |
| 29 | 任务首屏结构? | **上部** = 最近使用的 3 个项目卡片 + 「更多项目」入口;**下部** = 当前选中项目的任务管理视图,默认上次访问的项目(T-VW-08/09) |
| 30 | 首屏「任务卡片可以排序」的指代? | 指**最近项目卡片**可长按拖动排序,结果存设备端;任务卡片的列内排序维持既有口径(T-VW-10) |
| 31 | 成员 / 列设置入口的位置? | 统一收进「**项目管理**」入口(含成员、列设置、项目信息三区),项目页头部不再散置(T-PJ-11) |
| 32 | 项目信息管理包含什么? | 改名、归档 / 取消归档、删除(个人项目禁用)、退出(创建人可退),入口在项目管理内(T-PJ-12) |
| 33 | 任务详情底部按钮遮挡? | 弹层底部预留**安全区 + 12px** 距离;小屏下弹层可滚动,底部按钮完整可见(T-UI-11) |
| 34 | 项目相关长文本信息记录在哪? | 项目管理内新增**信息块**分区:任意多个 {标题, 长文本} 块,独立编辑,属项目级数据(T-PJ-13) |
| 35 | 任务级「扩展信息」的去留? | **移除 UI**(2026-10-24 评审):任务不再带扩展信息,项目相关长文本由项目信息块(T-PJ-13)承载;DB 字段保留不用 |
**仍待评审确认(影响实现口径):**
| # | 问题 | 说明 |
| --- | --- | --- |
| A | 列数量上限是否按 10 列? | 现按「至少 1 列、至多 10 列」写入(T-COL-06)。若无需上限,可去掉上限约束 |
---
## 附录 A:核心实体草案(供设计阶段参考,非最终表结构)
```prisma
// 项目
model Project {
id Int @id @default(autoincrement())
name String
ownerId Int // 创建人 (可退出项目, 故与成员表分离)
isPersonal Boolean @default(false) // true = 个人默认项目: 不可删、不可加成员
archived Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
members ProjectMember[]
columns BoardColumn[]
tasks Task[]
@@index([archived])
}
// 看板列 (项目级实体: 默认 4 列, 可增删改与排序)
model BoardColumn {
id Int @id @default(autoincrement())
projectId Int
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
name String // 列名, 项目内唯一
sortOrder Int @default(0) // 显示顺序, 决定自动归位时的"第一个匹配列"
boundStatus String? // null = 不绑定; todo | doing | done
retention String @default("forever") // never | today | 3d | week | month | forever
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
tasks Task[]
@@unique([projectId, name])
@@index([projectId, sortOrder])
}
// 项目成员 (权限单位: 在表内即全读写)
model ProjectMember {
id Int @id @default(autoincrement())
projectId Int
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
userId Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
permission String @default("member") // 预留权限分级入口, 本期恒为 member
createdAt DateTime @default(now())
@@unique([projectId, userId])
@@index([userId])
}
// 任务 / 子任务 (parentId 为空 = 顶层任务)
model Task {
id Int @id @default(autoincrement())
projectId Int
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
parentId Int? // 非空 = 一级子任务 (最多一层)
parent Task? @relation("Subtask", fields: [parentId], references: [id], onDelete: Cascade)
children Task[] @relation("Subtask")
title String
description String @default("")
status String @default("todo") // todo | doing | done
columnId Int // 所属看板列 (BoardColumn), 列可增删改故用 id 而非固定名
column BoardColumn @relation(fields: [columnId], references: [id])
assigneeId Int? // 负责人 (可空; 新建默认 = 创建者)
assignee User? @relation("Assignee", fields: [assigneeId], references: [id], onDelete: SetNull)
creatorId Int // 创建人 (跨项目拷贝时 = 操作者)
creator User @relation("Creator", fields: [creatorId], references: [id])
// 扩展信息卡片: 任意条目, 默认收起
extras Json @default("[]") // [{ label: "仓库", value: "gitlab/app", url: "" }]
sortOrder Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
members TaskMember[]
tags TaskTag[]
@@index([projectId, columnId])
@@index([projectId, status])
@@index([parentId])
}
// 任务成员 (多人, 须为项目成员)
model TaskMember {
id Int @id @default(autoincrement())
taskId Int
task Task @relation(fields: [taskId], references: [id], onDelete: Cascade)
userId Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([taskId, userId])
}
// 任务标签
model TaskTag {
id Int @id @default(autoincrement())
taskId Int
task Task @relation(fields: [taskId], references: [id], onDelete: Cascade)
name String
@@index([taskId])
}
```
> 说明:
> 1. `status` 与 `columnId` **两个字段都落库**(而非互相推导),因为未绑定的列需要二者彼此独立;联动仅在项目配置了绑定的列/状态之间发生,由服务端统一裁决。
> 2. 保留选项的判定依赖「在该列的完成时间」,建议在任务上记录完成时间或按列迁移时间计算,具体口径在设计阶段确定。
> 3. 视图记忆存设备端(localStorage),不落库。
---
## 附录 B:实现约定(开发期补充,用于消除口径歧义)
| # | 约定 | 说明 |
| --- | --- | --- |
| B-1 | **看板列** | 列是项目级实体(`BoardColumn`),含名称与顺序;默认 4 列 `Plan/Doing/Action/Done`;支持增删改与排序,至少保留 1 列 |
| B-2 | **列-状态绑定** | `columnBindings` 为「列 → 状态或 null」映射;null 表示不绑定;**允许多列指向同一状态**;状态枚举 `todo` / `doing` / `done` |
| B-3 | **多列绑定同一状态时的目标列** | 移入列时按**当前列顺序**取**第一个**绑定该状态的列(列顺序可被用户调整) |
| B-4 | **列决定状态** | 拖动任务时只看**目标列**:目标列绑定了状态 ⇒ 状态 = 所绑定状态(含从任意列拖入);目标列未绑定 ⇒ 状态保持原值。**不存在「从某列拖出即回退」的规则** |
| B-4b | **状态与列不一致时的自动归位** | 状态改变后,若当前所在列未绑定该状态 ⇒ 按列序取第一个绑定该状态的列并移入;**无匹配列则不移动**,状态照常改变 |
| B-5 | **列保留选项** | `columnRetention` 为「列 → 选项」映射,选项为固定 6 档:`never`(永不显示)/ `today`(当天)/ `3d`(3 天)/ `week`(本周)/ `month`(本月)/ `forever`(永久显示) |
| B-5b | **各档位口径** | 均按 Asia/Shanghai 自然日:`today` = 今天;`3d` = 含今天在内的最近 3 天;`week` = 本周一 00:00 起;`month` = 本月 1 日 00:00 起;判定依据为任务的**完成时间** |
| B-6 | **显示控制作用域** | 列表视图与看板视图**同样**按任务**当前所属列**的保留选项执行过滤;列表只是不按列分组,归属不变;未完成任务不受影响 |
| B-7 | **子任务与绑定无关** | 子任务状态**不受**看板列-状态绑定影响:拖入列不改写子任务状态,修改子任务状态也不触发「移入列」 |
| B-8 | **子任务约束** | `parentId` 非空时:父任务 `parentId` 必须为空(禁止二级);`projectId` 必须与父任务一致;禁止指向自身或后代 |
| B-9 | **拖拽降为子任务** | 长按拖动任务 A 到任务 B 上释放 ⇒ A.parentId = B.id;要求 B 为顶层任务且 A 本身无子任务,否则拒绝 |
| B-10 | **负责人默认值** | 新建任务时若未显式指定,`assigneeId` 默认取创建者的 userId;显式传 null 表示清空 |
| B-11 | **个人默认项目** | `isPersonal = true`;服务端拒绝「添加成员」「删除项目」;名称初始为 `{用户名}的任务`,可改名 |
| B-12 | **成员清空** | 移除最后一名成员时不删除 `Project`,仅删除 `ProjectMember` 记录;前端不特判 |
| B-13 | **跨项目拷贝** | 新建任务(新 id)+ 复制全部字段、扩展卡片与子任务;`creatorId` = 操作者;负责人 / 成员按 T-EX-08 过滤;状态与列按 T-EX-14 沿用 |
| B-14 | **权限校验** | 所有项目 / 任务接口均先校验「请求者为该项目成员」,非成员返回 403 |
| B-15 | **偏好存储** | 视图记忆与「上次访问的项目」存设备端;列定义、列-状态绑定、列保留选项属项目配置,存服务端、项目内共享 |
| B-16 | **看板列实体** | 列为 `BoardColumn`(name / sortOrder / boundStatus / retention);默认 4 列由新建项目时初始化;至少保留 1 列;列名项目内唯一 |
| B-17 | **删除列的迁移** | 删除列前需二次确认;列内任务迁移至指定列(默认相邻列),迁移后任务状态按目标列绑定重新裁决;**唯一剩余列不可删除** |
| B-18 | **新增列默认值** | 新增列取默认值:`retention = forever`、`boundStatus = null`,**不继承**相邻列配置;`sortOrder` 追加到末尾 |
| B-19 | **列数上限** | 至少 1 列、至多 10 列(上限待确认) |
+325
View File
@@ -0,0 +1,325 @@
# iBoard V0.2 数据库设计文档
| 项 | 内容 |
| --- | --- |
| 版本 | V0.2 |
| 文档状态 | 已实现(与代码一致) |
| 数据库 | MariaDB / MySQL 8(Prisma `provider = "mysql"`) |
| 字符集 | `utf8mb4 / utf8mb4_unicode_ci` |
| 时区约定 | 应用层统一 `Asia/Shanghai`,库内 `DATETIME` 存 UTC |
| 相关文件 | `prisma/schema.prisma`、`prisma/migrations/20261022120000_add_checkin_checklist/migration.sql` |
| 关联文档 | `需求文档.md`、`设计文档.md`、`界面设计.html` |
---
## 1. 概述
V0.2 新增「打卡 / 日历 / 清单」三个功能域,共引入 **5 张表**:
| 表 | 作用 | 归属域 |
| --- | --- | --- |
| `Habit` | 打卡项(每天/每周/每月/每年 n 次的目标定义) | 打卡 |
| `CheckIn` | 打卡记录(一次打卡 = 一条) | 打卡 |
| `CheckList` | 检查清单(出差/回老家等场景的一次性清单) | 清单 |
| `CheckListItem` | 检查项(勾选即完成) | 清单 |
| `CheckListTemplate` | 清单模板(系统预置 + 用户自定义) | 清单 |
日历视图**不新增表**:完全由 `Habit` + `CheckIn` 按月聚合得出(见 §7.3)。
### 1.1 设计原则
1. **记录为真,进度不落库。** 环形进度、完成率、streak 一律由 `CheckIn` 现场聚合,不在 `Habit` 上冗余 `doneCount` 字段——避免并发计数不一致、以及用户中途把「每周 3 次」改成「每周 5 次」后需要回填历史的问题。
2. **日期以本地日历日字符串存储。** `CheckIn.date` 用 `VARCHAR(10)` 存 `"YYYY-MM-DD"`(Asia/Shanghai 下的日历日),而不是 `DATETIME`:范围查询走索引、字典序等于时间序、天然幂等边界,且彻底规避「UTC 存 + 本地读」导致的跨零点归属错误。
3. **软删除优先。** 打卡项删除默认走 `archived` 归档(保留历史统计),只有显式 `?hard=1` 才物理删除。
4. **一切按 `userId` 隔离。** 所有业务表都带 `userId`,`Habit`/`CheckIn`/`CheckList`/`CheckListTemplate` 外键 `ON DELETE CASCADE` 到 `User`。
5. **迁移只增不改。** 新增需求一律新迁移文件,不修改已执行的迁移(部署走 `prisma migrate deploy`)。
---
## 2. 概念模型(ER)
```
┌──────────┐
│ User │ (既有表, 本次仅新增反向关系)
└────┬─────┘
┌───────────────┼────────────────────┬─────────────────┐
│ │ │ │
┌────▼────┐ ┌────▼────┐ ┌─────▼─────┐ ┌──────▼──────┐
│ Habit │1───*│ CheckIn │ │ CheckList │1─* │CheckListItem│
└─────────┘ └─────────┘ └───────────┘ └─────────────┘
打卡项 打卡记录 场景清单 检查项
(周期+目标n) (habitId,date,seq) (done, doneAt)
▲
│ 生成(复制条目, 不建外键)
┌─────┴──────────┐
│CheckListTemplate│
│ (builtin/自定义) │
└────────────────┘
```
基数说明:
| 关系 | 基数 | 级联 |
| --- | --- | --- |
| User → Habit | 1 : N | Cascade |
| User → CheckIn | 1 : N | Cascade |
| Habit → CheckIn | 1 : N | Cascade |
| User → CheckList | 1 : N | Cascade |
| CheckList → CheckListItem | 1 : N | Cascade |
| User → CheckListTemplate | 1 : N(可空,系统预置为空) | Cascade |
| CheckListTemplate → CheckList | 无外键,创建时**复制**条目内容 | — |
---
## 3. 表结构
### 3.1 `Habit` — 打卡项
周期目标的**定义**表,不含任何计数。
| 字段 | 类型 | 空 | 默认 | 说明 |
| --- | --- | --- | --- | --- |
| `id` | INT PK | 否 | AUTO_INCREMENT | 主键 |
| `userId` | INT FK | 否 | — | 所属用户 |
| `name` | VARCHAR(191) | 否 | — | 名称,接口校验 1~20 字 |
| `period` | VARCHAR(191) | 否 | `'day'` | `day` \| `week` \| `month` \| `year` |
| `target` | INT | 否 | `1` | 周期内目标次数 n(校验 ≥1) |
| `color` | VARCHAR(191) | 否 | `'#1677ff'` | 环形与标记主色(7 色预设之一) |
| `icon` | VARCHAR(191) | 否 | `''` | emoji / 图标名,**V0.2 预留不展示** |
| `note` | VARCHAR(191) | 否 | `''` | 备注 |
| `archived` | BOOLEAN | 否 | `false` | 归档后不出现在待办,历史保留 |
| `sortOrder` | INT | 否 | `0` | 排序权重(升序) |
| `createdAt` | DATETIME(3) | 否 | now() | 用于「创建之前的日期不计入月历期望」 |
| `updatedAt` | DATETIME(3) | 否 | 自动 | — |
索引:`INDEX(userId, archived, sortOrder)` — 覆盖「取当前用户未归档打卡项」主查询。
### 3.2 `CheckIn` — 打卡记录
**一次打卡 = 一条记录**,同日多次用 `seq` 递增。
| 字段 | 类型 | 空 | 默认 | 说明 |
| --- | --- | --- | --- | --- |
| `id` | INT PK | 否 | AUTO_INCREMENT | 主键 |
| `habitId` | INT FK | 否 | — | 所属打卡项 |
| `userId` | INT FK | 否 | — | 冗余用户(用于单用户全量区间查询,避免 join) |
| `date` | VARCHAR(10) | 否 | — | 本地日历日 `"YYYY-MM-DD"` |
| `seq` | INT | 否 | `1` | 当日第几次(1..n,超额继续累加) |
| `note` | VARCHAR(191) | 否 | `''` | 单次备注(接口截断 200 字) |
| `source` | VARCHAR(191) | 否 | `'user'` | `user` \| `backfill`(补打) |
| `createdAt` | DATETIME(3) | 否 | now() | 打卡时刻(UTC) |
约束与索引:
| 名称 | 类型 | 字段 | 作用 |
| --- | --- | --- | --- |
| `CheckIn_habitId_date_seq_key` | **UNIQUE** | `(habitId, date, seq)` | 并发重复提交的幂等防线(冲突 → P2002 → 409) |
| `CheckIn_userId_date_idx` | INDEX | `(userId, date)` | 月历按月区间扫全量记录 |
| `CheckIn_habitId_date_idx` | INDEX | `(habitId, date)` | 单打卡项周期区间聚合 |
### 3.3 `CheckList` — 检查清单
| 字段 | 类型 | 空 | 默认 | 说明 |
| --- | --- | --- | --- | --- |
| `id` | INT PK | 否 | AUTO_INCREMENT | 主键 |
| `userId` | INT FK | 否 | — | 所属用户 |
| `title` | VARCHAR(191) | 否 | — | 清单名,如「出差 · 杭州」 |
| `scene` | VARCHAR(191) | 否 | `''` | 场景:`出差` \| `回老家` \| `其他` |
| `note` | VARCHAR(191) | 否 | `''` | 备注 |
| `archived` | BOOLEAN | 否 | `false` | 归档 |
| `createdAt` / `updatedAt` | DATETIME(3) | 否 | — | 列表默认按 `updatedAt` 倒序 |
索引:`INDEX(userId, archived)` — 对应「进行中 / 已归档」两个分段列表。
### 3.4 `CheckListItem` — 检查项
| 字段 | 类型 | 空 | 默认 | 说明 |
| --- | --- | --- | --- | --- |
| `id` | INT PK | 否 | AUTO_INCREMENT | 主键 |
| `listId` | INT FK | 否 | — | 所属清单 |
| `title` | VARCHAR(191) | 否 | — | 条目名(校验 ≤40 字) |
| `note` | VARCHAR(191) | 否 | `''` | 条目备注 |
| `done` | BOOLEAN | 否 | `false` | 是否已完成 |
| `doneAt` | DATETIME(3) | **是** | NULL | 勾选时刻;取消勾选置回 NULL |
| `sortOrder` | INT | 否 | `0` | 排序(升序,同值按 id) |
| `createdAt` / `updatedAt` | DATETIME(3) | 否 | — | — |
索引:`INDEX(listId, sortOrder)`。
### 3.5 `CheckListTemplate` — 清单模板
| 字段 | 类型 | 空 | 默认 | 说明 |
| --- | --- | --- | --- | --- |
| `id` | INT PK | 否 | AUTO_INCREMENT | 主键 |
| `userId` | INT FK | **是** | NULL | NULL = 系统预置(`builtin=true`) |
| `name` | VARCHAR(191) | 否 | — | 模板名 |
| `scene` | VARCHAR(191) | 否 | `''` | 场景标签 |
| `items` | **JSON** | 否 | — | `[{ title, note }]`,创建清单时展开为 `CheckListItem` |
| `builtin` | BOOLEAN | 否 | `false` | 系统预置:只读、不可删(接口层限制) |
| `createdAt` / `updatedAt` | DATETIME(3) | 否 | — | — |
索引:`INDEX(userId, builtin)` — 查询条件为 `builtin=true OR userId=me`。
系统预置数据(首次访问 `GET /api/checklists/templates` 时**幂等写入**):
| 模板 | 条目数 | 条目 |
| --- | --- | --- |
| 出差 | 11 | 身份证/证件、手机充电器+充电宝、笔记本电脑+电源、耳机、换洗衣物、洗漱包、常用药、雨伞、车票/机票/酒店确认、工牌/名片、报销单据 |
| 回老家 | 12 | 身份证/证件、钥匙、手机充电器+充电宝、换洗衣物、洗漱包、常用药、给家人的礼物/伴手礼、长辈的药品/营养品、口罩、现金零钱、车票/加油/行程确认、家里门窗水电检查 |
---
## 4. 外键与级联
| 子表.字段 | 父表.字段 | ON DELETE | ON UPDATE |
| --- | --- | --- | --- |
| `Habit.userId` | `User.id` | CASCADE | CASCADE |
| `CheckIn.habitId` | `Habit.id` | CASCADE | CASCADE |
| `CheckIn.userId` | `User.id` | CASCADE | CASCADE |
| `CheckList.userId` | `User.id` | CASCADE | CASCADE |
| `CheckListItem.listId` | `CheckList.id` | CASCADE | CASCADE |
| `CheckListTemplate.userId` | `User.id` | CASCADE | CASCADE |
删除链路:删用户 → 级联清 Habit/CheckIn/CheckList/Template;删 Habit → 清其全部 CheckIn;删 CheckList → 清其全部 Item。业务层删打卡项默认只置 `archived=true`,物理删需 `?hard=1`(此时先 `deleteMany` 记录再删主表)。
---
## 5. 关键设计决策
### 5.1 为什么 `date` 用 `VARCHAR(10)` 而不是 `DATETIME`
| 维度 | VARCHAR(10) 本地日历日 | DATETIME(UTC) |
| --- | --- | --- |
| 跨零点归属 | 明确:由应用层按 Asia/Shanghai 计算一次,落库即定 | 读时需要再转时区,易错 |
| 范围查询 | `date >= '2026-10-01' AND date <= '2026-10-31'` 直接走索引 | 需函数/边界换算,易失效 |
| 幂等边界 | `UNIQUE(habitId, date, seq)` 天然按「日」收敛 | 需按日截取 |
| 字典序 | 等于时间序,可安全字符串比较 | — |
| 存储 | 10 字节 | 5 字节(略优,但不足以抵消上述成本) |
代价:跨时区展示需自行换算——本项目全链路 Asia/Shanghai,不成为问题。
### 5.2 为什么不冗余 `doneCount` / `streak`
- **并发**:计数需事务 + 行锁,聚合查询天然一致。
- **改配置**:用户把「每周 3 次」改成「每周 5 次」,冗余计数立刻失真,需要全量重算;现方案改 `target` 立即生效。
- **撤销**:撤销即删一条记录,无需维护计数器。
- **成本**:单个用户打卡项数量在 10~30 量级,月历查询一次拉取整月记录(≤ 数百行)在内存聚合,开销可接受。
### 5.3 `seq` 与幂等
同日多次打卡用 `seq = max(seq)+1`。唯一约束 `(habitId, date, seq)` 使并发重复提交必然撞 P2002,服务端转 409「该次打卡已记录」,前端幂等收敛,不会出现「点两下加两次」的体验事故。
### 5.4 归档 vs 物理删除
| 操作 | 语义 | 数据影响 |
| --- | --- | --- |
| `DELETE /api/checkin/habits/:id` | 归档(默认) | `archived=true`,记录全保留 |
| `DELETE /api/checkin/habits/:id?hard=1` | 物理删除 | 先删 CheckIn,再删 Habit |
| `DELETE /api/checklists/:id` | 物理删除(一次性清单,不留历史) | 先删 Item,再删 List |
理由:打卡是长期行为数据,历史构成 streak 与完成率;清单是短期事务,用完即归档/删除。
### 5.5 模板用 JSON 而非明细表
模板条目结构固定(`title` + `note`)、只在「创建清单」时整体读取并展开、不需要按条目检索或统计 —— 用 `JSON` 列避免额外的 `TemplateItem` 表与 join。展开后落到 `CheckListItem`,之后模板改动不影响已生成清单。
---
## 6. 数据量估算
单用户规模假设:打卡项 20 个,平均每天打卡 25 次;清单每月 4 份,每份 12 条。
| 表 | 年增量(单用户) | 行大小(约) | 年占用(约) |
| --- | --- | --- | --- |
| `CheckIn` | 25 × 365 ≈ 9,100 | ~80 B | ~0.7 MB |
| `CheckListItem` | 4 × 12 × 12 = 576 | ~90 B | ~0.05 MB |
| `Habit` / `CheckList` / `CheckListTemplate` | < 100 | ~200 B | 忽略 |
结论:**无需分区、无需归档策略**。10 用户 × 5 年 ≈ 45 万行 CheckIn,仍远低于单表瓶颈。若未来接入多租户或做年视图长周期统计,再考虑按月汇总表。
---
## 7. 典型查询
### 7.1 今日待办 + 周期进度
```sql
-- 步骤 1: 取未归档打卡项 (走 Habit_userId_archived_sortOrder_idx)
SELECT id, name, period, target, color FROM Habit
WHERE userId = ? AND archived = 0 ORDER BY sortOrder, id;
-- 步骤 2: 取覆盖所有周期窗口的最小区间内的记录 (走 CheckIn_userId_date_idx)
SELECT habitId, date FROM CheckIn
WHERE userId = ? AND date >= ? AND date <= ?; -- 如 week: '2026-10-19' ~ '2026-10-25'
-- 步骤 3: 内存按 habitId/date 分组 -> 计算 done/percent/streak
```
### 7.2 打一次卡
```sql
SELECT MAX(seq) FROM CheckIn WHERE habitId = ? AND date = ?; -- -> seq
INSERT INTO CheckIn (habitId, userId, date, seq, note, source) VALUES (?, ?, ?, ?, ?, 'user');
-- 冲突 -> P2002 -> 409
```
撤销(撤销本周期最近一次):
```sql
SELECT id FROM CheckIn WHERE habitId = ? AND date BETWEEN ? AND ?
ORDER BY date DESC, seq DESC LIMIT 1;
DELETE FROM CheckIn WHERE id = ?;
```
### 7.3 月历(日历视图数据来源)
```sql
SELECT habitId, date FROM CheckIn
WHERE userId = ? AND date >= '2026-10-01' AND date <= '2026-10-31';
```
按天聚合后与 `Habit.expectedPerDay` 比较得出日状态:
| 状态 | 条件 | 展示 |
| --- | --- | --- |
| `full` | 全部打卡项当日次数 ≥ 日均期望 | 绿点 |
| `over` | 有任一打卡项当日次数 > 日均期望 | 紫点 |
| `partial` | 有完成但未全完成 | 黄点 |
| `empty` | 无记录或当天无打卡项 | 无点 |
`Habit.createdAt` 早于该日的才计入期望,避免新建打卡项把历史日期标成「未完成」。
### 7.4 清单列表(带进度)
```sql
SELECT l.*, i.* FROM CheckList l
LEFT JOIN CheckListItem i ON i.listId = l.id
WHERE l.userId = ? AND l.archived = ?
ORDER BY l.updatedAt DESC, i.sortOrder, i.id;
```
---
## 8. 迁移
| 项 | 内容 |
| --- | --- |
| 迁移目录 | `prisma/migrations/20261022120000_add_checkin_checklist/` |
| 内容 | 5 张 `CREATE TABLE` + 6 条 `ADD CONSTRAINT`(见 `migration.sql`) |
| 生成方式 | 手写 SQL(与 `prisma migrate diff` 产出格式一致),已通过 `prisma migrate deploy` 验证 |
| 部署命令 | `npx prisma migrate deploy && npx prisma generate` |
| 回滚 | 无自动回滚。应急:`DROP TABLE CheckIn, CheckListItem, CheckList, CheckListTemplate, Habit;`(注意外键顺序)并把 `_prisma_migrations` 中该记录删除 |
>V0.2 全为新增表与新增字段(`User` 侧仅新增 Prisma 反向关系,不产生 DDL),对既有数据零影响。
---
## 9. V0.3 预留
| 方向 | 可能的表变更 |
| --- | --- |
| 打卡提醒 | `Habit` 增加 `remindAt TIME`、`remindDays` |
| 打卡统计快照 | 新增 `HabitDailyStat(userId, date, done, expected)` 汇总表,用于年视图/热力图加速 |
| 清单共享协作 | 新增 `CheckListShare(listId, userId, role)` |
| 模板市场 | `CheckListTemplate` 增加 `visibility`、`useCount` |
以上均可通过「新增表 / 新增可空字段」完成,不影响 V0.2 数据结构。
File diff suppressed because it is too large Load Diff
+516
View File
@@ -0,0 +1,516 @@
# 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 导航。
---
## 11. 界面调整(2026-10-23)
### 11.1 变更清单
| # | 范围 | 变更 | 状态 |
| --- | --- | --- | --- |
| 1 | 页面头部 | 打卡 NavBar 高度减半(45 → 23px)、标题改主色 12px;宏观 / 监控补标题;「我」页整条去掉 | **已实现** |
| 2 | 打卡页 | 日历与打卡合并为一个视图(上日历、下打卡,按天/周/月分组、去掉年分组、卡片紧凑化) | **待确认**(设计稿已出,见《界面设计.html》2.12 ②节) |
| 3 | 清单页 | 新增「模板」分类(与进行中 / 已归档并列),独立 CRUD + 字母序;新建清单去掉场景、模板改右侧滑出选择 | **已实现** |
| 4 | 宏观页 | 报警 / 新闻移到最左并默认选中新闻;tab 条增加右侧溢出提示 | **已实现** |
### 11.2 页面头部统一
新增组件 `src/components/PageHeader.jsx`(高度 23px、标题 12px/600 主色、字距 1px、背景取页面底色 `#f5f5f5`),只被 **打卡 / 宏观 / 监控** 三页引用:
| 页 | 变更前 | 变更后 |
| --- | --- | --- |
| 打卡 | `NavBar` 45px,标题 17px 深色 | `PageHeader` 23px,标题主色 |
| 宏观 | `NavBar` 45px,空白 | `PageHeader` + 标题「宏观」 |
| 监控 | `NavBar` 45px,空白 | `PageHeader` + 标题「监控」 |
| 我 | `NavBar` 45px 空白条 | **移除**,`me-profile` 直接贴顶 |
> 「我」页不引用该组件,因此调整打卡/宏观/监控的头部不会波及「我」;反之亦然。三个页面之间也各自持有独立实例,互不影响。
### 11.3 清单模板分类
**接口**(`server/routes/checklists.js`):
| 方法 | 路径 | 说明 |
| --- | --- | --- |
| GET | `/api/checklists/templates` | 列表,**服务端按名称字母顺序排序**(原为 `builtin desc, id asc`) |
| GET | `/api/checklists/templates/:id` | 详情(新增) |
| POST | `/api/checklists/templates` | 新建(名称 ≤20 字,≥1 条检查项) |
| PUT | `/api/checklists/templates/:id` | 改名称 / 检查项(新增;`builtin=true` 拒绝,404) |
| DELETE | `/api/checklists/templates/:id` | 删自建模板(`builtin=true` 拒绝) |
排序口径:`Intl.Collator('zh-Hans-CN', { numeric: true, sensitivity: 'base' })` —— 中文按拼音、数字按数值。前端 `ListView` 拿到列表后再排一次,保证与后端一致(网络返回顺序不可控时也不乱)。
**前端**(`src/pages/checkin/ListView.jsx`):
- 分段由 2 项扩为 3 项:`active`(进行中)/ `archived`(已归档)/ `template`(模板);右上角按钮随分段切换为「+ 新建」/「+ 新建模板」。
- 模板卡片:名称 + 「预置」标记 + 项数;最多展示 4 条检查项摘要,超出折叠为 `+n`;操作「由此新建 / 编辑 / 删除」,预置模板只保留「由此新建」。
- 新建 / 编辑模板共用底部弹层:名称 + 检查项逐条编辑(≤40 字,可删可加)。
- **新建清单表单**:仅「名称」+「模板」两项 —— **场景选择器已移除**(`scene` 改为沿用所选模板自带的场景,无模板则为空;数据库字段保留,历史数据照常展示)。
- **模板选择**:点击模板行从**右侧滑出** `78vw` 面板(antd-mobile `Popup position="right"`),首项固定「不使用模板」,其后为字母序模板列表(预置带标记 + 项数),选中项主色高亮。
### 11.4 宏观 tab 顺序与溢出提示
- Tab 顺序改为 **报警 → 新闻 → 油价 → 经济数据 → 策略研究 → 经济文章**,默认选中 **新闻**;`/macro` 由路由直接 `Navigate` 到 `/macro/news`(`App.jsx`),`activeFromPath` 兜底同样返回 `news`。
- `SwipeTabs` 增加溢出提示层:测量 `.adm-tabs-tab-list` 的 `scrollLeft + clientWidth < scrollWidth`,为真时在顶部右侧渲染 `.tab-more-hint`(白色渐变 + 主色圆底箭头「›」,1.5s 轻微摆动动画,点击向右翻页 60% 视宽),滚到末尾自动消失。
- 切换 tab 时把激活项平滑滚到可视区中央,避免选中项藏在屏幕外。
- 为什么不用框架自带的:antd-mobile Tabs 自带 `.adm-tabs-header-mask-right` 只有 30px 纯渐变、无箭头、无交互,用户难以判断"右侧还有内容",故叠加此提示层。
### 11.5 待确认项:日历与打卡合并
方案已写入《界面设计.html》2.12 ②节(含主视图、未来日期计划态、卡片「⋯」操作面板三屏)。要点:
| 项 | 方案 |
| --- | --- |
| 结构 | SwipeTabs 由 3 个减为 2 个:`日历(含打卡)` / `清单` |
| 日历 | 月历 + 月份切换 + 回到今天;**去掉「昨天/今天/明天」分段**;日期格 38 → 31px |
| 打卡 | 沿用现有卡片样式,紧凑化:内边距 12→8px、环形 58→42px(环宽 6→5)、间距 10→7px |
| 操作 | 撤销 / 编辑 / 归档收进「⋯」底部面板(原为卡片底部三个文字链接) |
| 分组 | **每天 → 每周 → 每月**,组标题左侧主色竖条;**去掉「每年」分组** |
| 联动 | 点日期即切换当日数据;未来日期只读,按钮灰显「计划」 |
确认后再落地:`CheckinPage` 收敛为 2 个 tab、`CalendarView` 与 `HabitView` 合成新视图组件、`HabitView` 的循环/编辑逻辑抽出复用、样式新增 `.mv-*` 段。
+348
View File
@@ -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` | 删除自定义模板 |
+25
View File
@@ -107,3 +107,28 @@ export type MihomeEnvSample = Prisma.MihomeEnvSampleModel
*
*/
export type MihomeEnvHour = Prisma.MihomeEnvHourModel
/**
* Model Habit
*
*/
export type Habit = Prisma.HabitModel
/**
* Model CheckIn
*
*/
export type CheckIn = Prisma.CheckInModel
/**
* Model CheckList
*
*/
export type CheckList = Prisma.CheckListModel
/**
* Model CheckListItem
*
*/
export type CheckListItem = Prisma.CheckListItemModel
/**
* Model CheckListTemplate
*
*/
export type CheckListTemplate = Prisma.CheckListTemplateModel
+25
View File
@@ -131,3 +131,28 @@ export type MihomeEnvSample = Prisma.MihomeEnvSampleModel
*
*/
export type MihomeEnvHour = Prisma.MihomeEnvHourModel
/**
* Model Habit
*
*/
export type Habit = Prisma.HabitModel
/**
* Model CheckIn
*
*/
export type CheckIn = Prisma.CheckInModel
/**
* Model CheckList
*
*/
export type CheckList = Prisma.CheckListModel
/**
* Model CheckListItem
*
*/
export type CheckListItem = Prisma.CheckListItemModel
/**
* Model CheckListTemplate
*
*/
export type CheckListTemplate = Prisma.CheckListTemplateModel
File diff suppressed because one or more lines are too long
+462 -2
View File
@@ -401,7 +401,12 @@ export const ModelName = {
MihomeHome: 'MihomeHome',
MihomeDevice: 'MihomeDevice',
MihomeEnvSample: 'MihomeEnvSample',
MihomeEnvHour: 'MihomeEnvHour'
MihomeEnvHour: 'MihomeEnvHour',
Habit: 'Habit',
CheckIn: 'CheckIn',
CheckList: 'CheckList',
CheckListItem: 'CheckListItem',
CheckListTemplate: 'CheckListTemplate'
} as const
export type ModelName = (typeof ModelName)[keyof typeof ModelName]
@@ -417,7 +422,7 @@ export type TypeMap<ExtArgs extends runtime.Types.Extensions.InternalArgs = runt
omit: GlobalOmitOptions
}
meta: {
modelProps: "user" | "article" | "strategy" | "indicator" | "indicatorPoint" | "news" | "alert" | "serverNode" | "serverNodeHistory" | "serviceCheck" | "econ_SowInventory" | "scraperCache" | "oilPriceHistory" | "mihomeAccount" | "mihomeHome" | "mihomeDevice" | "mihomeEnvSample" | "mihomeEnvHour"
modelProps: "user" | "article" | "strategy" | "indicator" | "indicatorPoint" | "news" | "alert" | "serverNode" | "serverNodeHistory" | "serviceCheck" | "econ_SowInventory" | "scraperCache" | "oilPriceHistory" | "mihomeAccount" | "mihomeHome" | "mihomeDevice" | "mihomeEnvSample" | "mihomeEnvHour" | "habit" | "checkIn" | "checkList" | "checkListItem" | "checkListTemplate"
txIsolationLevel: TransactionIsolationLevel
}
model: {
@@ -1609,6 +1614,336 @@ export type TypeMap<ExtArgs extends runtime.Types.Extensions.InternalArgs = runt
}
}
}
Habit: {
payload: Prisma.$HabitPayload<ExtArgs>
fields: Prisma.HabitFieldRefs
operations: {
findUnique: {
args: Prisma.HabitFindUniqueArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$HabitPayload> | null
}
findUniqueOrThrow: {
args: Prisma.HabitFindUniqueOrThrowArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$HabitPayload>
}
findFirst: {
args: Prisma.HabitFindFirstArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$HabitPayload> | null
}
findFirstOrThrow: {
args: Prisma.HabitFindFirstOrThrowArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$HabitPayload>
}
findMany: {
args: Prisma.HabitFindManyArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$HabitPayload>[]
}
create: {
args: Prisma.HabitCreateArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$HabitPayload>
}
createMany: {
args: Prisma.HabitCreateManyArgs<ExtArgs>
result: BatchPayload
}
delete: {
args: Prisma.HabitDeleteArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$HabitPayload>
}
update: {
args: Prisma.HabitUpdateArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$HabitPayload>
}
deleteMany: {
args: Prisma.HabitDeleteManyArgs<ExtArgs>
result: BatchPayload
}
updateMany: {
args: Prisma.HabitUpdateManyArgs<ExtArgs>
result: BatchPayload
}
upsert: {
args: Prisma.HabitUpsertArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$HabitPayload>
}
aggregate: {
args: Prisma.HabitAggregateArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.AggregateHabit>
}
groupBy: {
args: Prisma.HabitGroupByArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.HabitGroupByOutputType>[]
}
count: {
args: Prisma.HabitCountArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.HabitCountAggregateOutputType> | number
}
}
}
CheckIn: {
payload: Prisma.$CheckInPayload<ExtArgs>
fields: Prisma.CheckInFieldRefs
operations: {
findUnique: {
args: Prisma.CheckInFindUniqueArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$CheckInPayload> | null
}
findUniqueOrThrow: {
args: Prisma.CheckInFindUniqueOrThrowArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$CheckInPayload>
}
findFirst: {
args: Prisma.CheckInFindFirstArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$CheckInPayload> | null
}
findFirstOrThrow: {
args: Prisma.CheckInFindFirstOrThrowArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$CheckInPayload>
}
findMany: {
args: Prisma.CheckInFindManyArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$CheckInPayload>[]
}
create: {
args: Prisma.CheckInCreateArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$CheckInPayload>
}
createMany: {
args: Prisma.CheckInCreateManyArgs<ExtArgs>
result: BatchPayload
}
delete: {
args: Prisma.CheckInDeleteArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$CheckInPayload>
}
update: {
args: Prisma.CheckInUpdateArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$CheckInPayload>
}
deleteMany: {
args: Prisma.CheckInDeleteManyArgs<ExtArgs>
result: BatchPayload
}
updateMany: {
args: Prisma.CheckInUpdateManyArgs<ExtArgs>
result: BatchPayload
}
upsert: {
args: Prisma.CheckInUpsertArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$CheckInPayload>
}
aggregate: {
args: Prisma.CheckInAggregateArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.AggregateCheckIn>
}
groupBy: {
args: Prisma.CheckInGroupByArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.CheckInGroupByOutputType>[]
}
count: {
args: Prisma.CheckInCountArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.CheckInCountAggregateOutputType> | number
}
}
}
CheckList: {
payload: Prisma.$CheckListPayload<ExtArgs>
fields: Prisma.CheckListFieldRefs
operations: {
findUnique: {
args: Prisma.CheckListFindUniqueArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$CheckListPayload> | null
}
findUniqueOrThrow: {
args: Prisma.CheckListFindUniqueOrThrowArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$CheckListPayload>
}
findFirst: {
args: Prisma.CheckListFindFirstArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$CheckListPayload> | null
}
findFirstOrThrow: {
args: Prisma.CheckListFindFirstOrThrowArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$CheckListPayload>
}
findMany: {
args: Prisma.CheckListFindManyArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$CheckListPayload>[]
}
create: {
args: Prisma.CheckListCreateArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$CheckListPayload>
}
createMany: {
args: Prisma.CheckListCreateManyArgs<ExtArgs>
result: BatchPayload
}
delete: {
args: Prisma.CheckListDeleteArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$CheckListPayload>
}
update: {
args: Prisma.CheckListUpdateArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$CheckListPayload>
}
deleteMany: {
args: Prisma.CheckListDeleteManyArgs<ExtArgs>
result: BatchPayload
}
updateMany: {
args: Prisma.CheckListUpdateManyArgs<ExtArgs>
result: BatchPayload
}
upsert: {
args: Prisma.CheckListUpsertArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$CheckListPayload>
}
aggregate: {
args: Prisma.CheckListAggregateArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.AggregateCheckList>
}
groupBy: {
args: Prisma.CheckListGroupByArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.CheckListGroupByOutputType>[]
}
count: {
args: Prisma.CheckListCountArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.CheckListCountAggregateOutputType> | number
}
}
}
CheckListItem: {
payload: Prisma.$CheckListItemPayload<ExtArgs>
fields: Prisma.CheckListItemFieldRefs
operations: {
findUnique: {
args: Prisma.CheckListItemFindUniqueArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$CheckListItemPayload> | null
}
findUniqueOrThrow: {
args: Prisma.CheckListItemFindUniqueOrThrowArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$CheckListItemPayload>
}
findFirst: {
args: Prisma.CheckListItemFindFirstArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$CheckListItemPayload> | null
}
findFirstOrThrow: {
args: Prisma.CheckListItemFindFirstOrThrowArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$CheckListItemPayload>
}
findMany: {
args: Prisma.CheckListItemFindManyArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$CheckListItemPayload>[]
}
create: {
args: Prisma.CheckListItemCreateArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$CheckListItemPayload>
}
createMany: {
args: Prisma.CheckListItemCreateManyArgs<ExtArgs>
result: BatchPayload
}
delete: {
args: Prisma.CheckListItemDeleteArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$CheckListItemPayload>
}
update: {
args: Prisma.CheckListItemUpdateArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$CheckListItemPayload>
}
deleteMany: {
args: Prisma.CheckListItemDeleteManyArgs<ExtArgs>
result: BatchPayload
}
updateMany: {
args: Prisma.CheckListItemUpdateManyArgs<ExtArgs>
result: BatchPayload
}
upsert: {
args: Prisma.CheckListItemUpsertArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$CheckListItemPayload>
}
aggregate: {
args: Prisma.CheckListItemAggregateArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.AggregateCheckListItem>
}
groupBy: {
args: Prisma.CheckListItemGroupByArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.CheckListItemGroupByOutputType>[]
}
count: {
args: Prisma.CheckListItemCountArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.CheckListItemCountAggregateOutputType> | number
}
}
}
CheckListTemplate: {
payload: Prisma.$CheckListTemplatePayload<ExtArgs>
fields: Prisma.CheckListTemplateFieldRefs
operations: {
findUnique: {
args: Prisma.CheckListTemplateFindUniqueArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$CheckListTemplatePayload> | null
}
findUniqueOrThrow: {
args: Prisma.CheckListTemplateFindUniqueOrThrowArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$CheckListTemplatePayload>
}
findFirst: {
args: Prisma.CheckListTemplateFindFirstArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$CheckListTemplatePayload> | null
}
findFirstOrThrow: {
args: Prisma.CheckListTemplateFindFirstOrThrowArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$CheckListTemplatePayload>
}
findMany: {
args: Prisma.CheckListTemplateFindManyArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$CheckListTemplatePayload>[]
}
create: {
args: Prisma.CheckListTemplateCreateArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$CheckListTemplatePayload>
}
createMany: {
args: Prisma.CheckListTemplateCreateManyArgs<ExtArgs>
result: BatchPayload
}
delete: {
args: Prisma.CheckListTemplateDeleteArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$CheckListTemplatePayload>
}
update: {
args: Prisma.CheckListTemplateUpdateArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$CheckListTemplatePayload>
}
deleteMany: {
args: Prisma.CheckListTemplateDeleteManyArgs<ExtArgs>
result: BatchPayload
}
updateMany: {
args: Prisma.CheckListTemplateUpdateManyArgs<ExtArgs>
result: BatchPayload
}
upsert: {
args: Prisma.CheckListTemplateUpsertArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$CheckListTemplatePayload>
}
aggregate: {
args: Prisma.CheckListTemplateAggregateArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.AggregateCheckListTemplate>
}
groupBy: {
args: Prisma.CheckListTemplateGroupByArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.CheckListTemplateGroupByOutputType>[]
}
count: {
args: Prisma.CheckListTemplateCountArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.CheckListTemplateCountAggregateOutputType> | number
}
}
}
}
} & {
other: {
@@ -1924,6 +2259,81 @@ export const MihomeEnvHourScalarFieldEnum = {
export type MihomeEnvHourScalarFieldEnum = (typeof MihomeEnvHourScalarFieldEnum)[keyof typeof MihomeEnvHourScalarFieldEnum]
export const HabitScalarFieldEnum = {
id: 'id',
userId: 'userId',
name: 'name',
period: 'period',
target: 'target',
color: 'color',
icon: 'icon',
note: 'note',
archived: 'archived',
sortOrder: 'sortOrder',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
} as const
export type HabitScalarFieldEnum = (typeof HabitScalarFieldEnum)[keyof typeof HabitScalarFieldEnum]
export const CheckInScalarFieldEnum = {
id: 'id',
habitId: 'habitId',
userId: 'userId',
date: 'date',
seq: 'seq',
note: 'note',
source: 'source',
createdAt: 'createdAt'
} as const
export type CheckInScalarFieldEnum = (typeof CheckInScalarFieldEnum)[keyof typeof CheckInScalarFieldEnum]
export const CheckListScalarFieldEnum = {
id: 'id',
userId: 'userId',
title: 'title',
scene: 'scene',
note: 'note',
archived: 'archived',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
} as const
export type CheckListScalarFieldEnum = (typeof CheckListScalarFieldEnum)[keyof typeof CheckListScalarFieldEnum]
export const CheckListItemScalarFieldEnum = {
id: 'id',
listId: 'listId',
title: 'title',
note: 'note',
done: 'done',
doneAt: 'doneAt',
sortOrder: 'sortOrder',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
} as const
export type CheckListItemScalarFieldEnum = (typeof CheckListItemScalarFieldEnum)[keyof typeof CheckListItemScalarFieldEnum]
export const CheckListTemplateScalarFieldEnum = {
id: 'id',
userId: 'userId',
name: 'name',
scene: 'scene',
items: 'items',
builtin: 'builtin',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
} as const
export type CheckListTemplateScalarFieldEnum = (typeof CheckListTemplateScalarFieldEnum)[keyof typeof CheckListTemplateScalarFieldEnum]
export const SortOrder = {
asc: 'asc',
desc: 'desc'
@@ -2128,6 +2538,51 @@ export const MihomeEnvSampleOrderByRelevanceFieldEnum = {
export type MihomeEnvSampleOrderByRelevanceFieldEnum = (typeof MihomeEnvSampleOrderByRelevanceFieldEnum)[keyof typeof MihomeEnvSampleOrderByRelevanceFieldEnum]
export const HabitOrderByRelevanceFieldEnum = {
name: 'name',
period: 'period',
color: 'color',
icon: 'icon',
note: 'note'
} as const
export type HabitOrderByRelevanceFieldEnum = (typeof HabitOrderByRelevanceFieldEnum)[keyof typeof HabitOrderByRelevanceFieldEnum]
export const CheckInOrderByRelevanceFieldEnum = {
date: 'date',
note: 'note',
source: 'source'
} as const
export type CheckInOrderByRelevanceFieldEnum = (typeof CheckInOrderByRelevanceFieldEnum)[keyof typeof CheckInOrderByRelevanceFieldEnum]
export const CheckListOrderByRelevanceFieldEnum = {
title: 'title',
scene: 'scene',
note: 'note'
} as const
export type CheckListOrderByRelevanceFieldEnum = (typeof CheckListOrderByRelevanceFieldEnum)[keyof typeof CheckListOrderByRelevanceFieldEnum]
export const CheckListItemOrderByRelevanceFieldEnum = {
title: 'title',
note: 'note'
} as const
export type CheckListItemOrderByRelevanceFieldEnum = (typeof CheckListItemOrderByRelevanceFieldEnum)[keyof typeof CheckListItemOrderByRelevanceFieldEnum]
export const CheckListTemplateOrderByRelevanceFieldEnum = {
name: 'name',
scene: 'scene'
} as const
export type CheckListTemplateOrderByRelevanceFieldEnum = (typeof CheckListTemplateOrderByRelevanceFieldEnum)[keyof typeof CheckListTemplateOrderByRelevanceFieldEnum]
/**
* Field references
@@ -2302,6 +2757,11 @@ export type GlobalOmitConfig = {
mihomeDevice?: Prisma.MihomeDeviceOmit
mihomeEnvSample?: Prisma.MihomeEnvSampleOmit
mihomeEnvHour?: Prisma.MihomeEnvHourOmit
habit?: Prisma.HabitOmit
checkIn?: Prisma.CheckInOmit
checkList?: Prisma.CheckListOmit
checkListItem?: Prisma.CheckListItemOmit
checkListTemplate?: Prisma.CheckListTemplateOmit
}
/* Types for Logging */
@@ -68,7 +68,12 @@ export const ModelName = {
MihomeHome: 'MihomeHome',
MihomeDevice: 'MihomeDevice',
MihomeEnvSample: 'MihomeEnvSample',
MihomeEnvHour: 'MihomeEnvHour'
MihomeEnvHour: 'MihomeEnvHour',
Habit: 'Habit',
CheckIn: 'CheckIn',
CheckList: 'CheckList',
CheckListItem: 'CheckListItem',
CheckListTemplate: 'CheckListTemplate'
} as const
export type ModelName = (typeof ModelName)[keyof typeof ModelName]
@@ -363,6 +368,81 @@ export const MihomeEnvHourScalarFieldEnum = {
export type MihomeEnvHourScalarFieldEnum = (typeof MihomeEnvHourScalarFieldEnum)[keyof typeof MihomeEnvHourScalarFieldEnum]
export const HabitScalarFieldEnum = {
id: 'id',
userId: 'userId',
name: 'name',
period: 'period',
target: 'target',
color: 'color',
icon: 'icon',
note: 'note',
archived: 'archived',
sortOrder: 'sortOrder',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
} as const
export type HabitScalarFieldEnum = (typeof HabitScalarFieldEnum)[keyof typeof HabitScalarFieldEnum]
export const CheckInScalarFieldEnum = {
id: 'id',
habitId: 'habitId',
userId: 'userId',
date: 'date',
seq: 'seq',
note: 'note',
source: 'source',
createdAt: 'createdAt'
} as const
export type CheckInScalarFieldEnum = (typeof CheckInScalarFieldEnum)[keyof typeof CheckInScalarFieldEnum]
export const CheckListScalarFieldEnum = {
id: 'id',
userId: 'userId',
title: 'title',
scene: 'scene',
note: 'note',
archived: 'archived',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
} as const
export type CheckListScalarFieldEnum = (typeof CheckListScalarFieldEnum)[keyof typeof CheckListScalarFieldEnum]
export const CheckListItemScalarFieldEnum = {
id: 'id',
listId: 'listId',
title: 'title',
note: 'note',
done: 'done',
doneAt: 'doneAt',
sortOrder: 'sortOrder',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
} as const
export type CheckListItemScalarFieldEnum = (typeof CheckListItemScalarFieldEnum)[keyof typeof CheckListItemScalarFieldEnum]
export const CheckListTemplateScalarFieldEnum = {
id: 'id',
userId: 'userId',
name: 'name',
scene: 'scene',
items: 'items',
builtin: 'builtin',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
} as const
export type CheckListTemplateScalarFieldEnum = (typeof CheckListTemplateScalarFieldEnum)[keyof typeof CheckListTemplateScalarFieldEnum]
export const SortOrder = {
asc: 'asc',
desc: 'desc'
@@ -566,3 +646,48 @@ export const MihomeEnvSampleOrderByRelevanceFieldEnum = {
export type MihomeEnvSampleOrderByRelevanceFieldEnum = (typeof MihomeEnvSampleOrderByRelevanceFieldEnum)[keyof typeof MihomeEnvSampleOrderByRelevanceFieldEnum]
export const HabitOrderByRelevanceFieldEnum = {
name: 'name',
period: 'period',
color: 'color',
icon: 'icon',
note: 'note'
} as const
export type HabitOrderByRelevanceFieldEnum = (typeof HabitOrderByRelevanceFieldEnum)[keyof typeof HabitOrderByRelevanceFieldEnum]
export const CheckInOrderByRelevanceFieldEnum = {
date: 'date',
note: 'note',
source: 'source'
} as const
export type CheckInOrderByRelevanceFieldEnum = (typeof CheckInOrderByRelevanceFieldEnum)[keyof typeof CheckInOrderByRelevanceFieldEnum]
export const CheckListOrderByRelevanceFieldEnum = {
title: 'title',
scene: 'scene',
note: 'note'
} as const
export type CheckListOrderByRelevanceFieldEnum = (typeof CheckListOrderByRelevanceFieldEnum)[keyof typeof CheckListOrderByRelevanceFieldEnum]
export const CheckListItemOrderByRelevanceFieldEnum = {
title: 'title',
note: 'note'
} as const
export type CheckListItemOrderByRelevanceFieldEnum = (typeof CheckListItemOrderByRelevanceFieldEnum)[keyof typeof CheckListItemOrderByRelevanceFieldEnum]
export const CheckListTemplateOrderByRelevanceFieldEnum = {
name: 'name',
scene: 'scene'
} as const
export type CheckListTemplateOrderByRelevanceFieldEnum = (typeof CheckListTemplateOrderByRelevanceFieldEnum)[keyof typeof CheckListTemplateOrderByRelevanceFieldEnum]
+5
View File
@@ -26,4 +26,9 @@ export type * from './models/MihomeHome.ts'
export type * from './models/MihomeDevice.ts'
export type * from './models/MihomeEnvSample.ts'
export type * from './models/MihomeEnvHour.ts'
export type * from './models/Habit.ts'
export type * from './models/CheckIn.ts'
export type * from './models/CheckList.ts'
export type * from './models/CheckListItem.ts'
export type * from './models/CheckListTemplate.ts'
export type * from './commonInputTypes.ts'
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+558 -1
View File
@@ -224,6 +224,10 @@ export type UserWhereInput = {
password?: Prisma.StringFilter<"User"> | string
createdAt?: Prisma.DateTimeFilter<"User"> | Date | string
updatedAt?: Prisma.DateTimeFilter<"User"> | Date | string
habits?: Prisma.HabitListRelationFilter
checkIns?: Prisma.CheckInListRelationFilter
checkLists?: Prisma.CheckListListRelationFilter
templates?: Prisma.CheckListTemplateListRelationFilter
}
export type UserOrderByWithRelationInput = {
@@ -233,6 +237,10 @@ export type UserOrderByWithRelationInput = {
password?: Prisma.SortOrder
createdAt?: Prisma.SortOrder
updatedAt?: Prisma.SortOrder
habits?: Prisma.HabitOrderByRelationAggregateInput
checkIns?: Prisma.CheckInOrderByRelationAggregateInput
checkLists?: Prisma.CheckListOrderByRelationAggregateInput
templates?: Prisma.CheckListTemplateOrderByRelationAggregateInput
_relevance?: Prisma.UserOrderByRelevanceInput
}
@@ -246,6 +254,10 @@ export type UserWhereUniqueInput = Prisma.AtLeast<{
password?: Prisma.StringFilter<"User"> | string
createdAt?: Prisma.DateTimeFilter<"User"> | Date | string
updatedAt?: Prisma.DateTimeFilter<"User"> | Date | string
habits?: Prisma.HabitListRelationFilter
checkIns?: Prisma.CheckInListRelationFilter
checkLists?: Prisma.CheckListListRelationFilter
templates?: Prisma.CheckListTemplateListRelationFilter
}, "id" | "email">
export type UserOrderByWithAggregationInput = {
@@ -280,6 +292,10 @@ export type UserCreateInput = {
password: string
createdAt?: Date | string
updatedAt?: Date | string
habits?: Prisma.HabitCreateNestedManyWithoutUserInput
checkIns?: Prisma.CheckInCreateNestedManyWithoutUserInput
checkLists?: Prisma.CheckListCreateNestedManyWithoutUserInput
templates?: Prisma.CheckListTemplateCreateNestedManyWithoutUserInput
}
export type UserUncheckedCreateInput = {
@@ -289,6 +305,10 @@ export type UserUncheckedCreateInput = {
password: string
createdAt?: Date | string
updatedAt?: Date | string
habits?: Prisma.HabitUncheckedCreateNestedManyWithoutUserInput
checkIns?: Prisma.CheckInUncheckedCreateNestedManyWithoutUserInput
checkLists?: Prisma.CheckListUncheckedCreateNestedManyWithoutUserInput
templates?: Prisma.CheckListTemplateUncheckedCreateNestedManyWithoutUserInput
}
export type UserUpdateInput = {
@@ -297,6 +317,10 @@ export type UserUpdateInput = {
password?: Prisma.StringFieldUpdateOperationsInput | string
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
habits?: Prisma.HabitUpdateManyWithoutUserNestedInput
checkIns?: Prisma.CheckInUpdateManyWithoutUserNestedInput
checkLists?: Prisma.CheckListUpdateManyWithoutUserNestedInput
templates?: Prisma.CheckListTemplateUpdateManyWithoutUserNestedInput
}
export type UserUncheckedUpdateInput = {
@@ -306,6 +330,10 @@ export type UserUncheckedUpdateInput = {
password?: Prisma.StringFieldUpdateOperationsInput | string
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
habits?: Prisma.HabitUncheckedUpdateManyWithoutUserNestedInput
checkIns?: Prisma.CheckInUncheckedUpdateManyWithoutUserNestedInput
checkLists?: Prisma.CheckListUncheckedUpdateManyWithoutUserNestedInput
templates?: Prisma.CheckListTemplateUncheckedUpdateManyWithoutUserNestedInput
}
export type UserCreateManyInput = {
@@ -375,6 +403,16 @@ export type UserSumOrderByAggregateInput = {
id?: Prisma.SortOrder
}
export type UserScalarRelationFilter = {
is?: Prisma.UserWhereInput
isNot?: Prisma.UserWhereInput
}
export type UserNullableScalarRelationFilter = {
is?: Prisma.UserWhereInput | null
isNot?: Prisma.UserWhereInput | null
}
export type StringFieldUpdateOperationsInput = {
set?: string
}
@@ -391,6 +429,368 @@ export type IntFieldUpdateOperationsInput = {
divide?: number
}
export type UserCreateNestedOneWithoutHabitsInput = {
create?: Prisma.XOR<Prisma.UserCreateWithoutHabitsInput, Prisma.UserUncheckedCreateWithoutHabitsInput>
connectOrCreate?: Prisma.UserCreateOrConnectWithoutHabitsInput
connect?: Prisma.UserWhereUniqueInput
}
export type UserUpdateOneRequiredWithoutHabitsNestedInput = {
create?: Prisma.XOR<Prisma.UserCreateWithoutHabitsInput, Prisma.UserUncheckedCreateWithoutHabitsInput>
connectOrCreate?: Prisma.UserCreateOrConnectWithoutHabitsInput
upsert?: Prisma.UserUpsertWithoutHabitsInput
connect?: Prisma.UserWhereUniqueInput
update?: Prisma.XOR<Prisma.XOR<Prisma.UserUpdateToOneWithWhereWithoutHabitsInput, Prisma.UserUpdateWithoutHabitsInput>, Prisma.UserUncheckedUpdateWithoutHabitsInput>
}
export type UserCreateNestedOneWithoutCheckInsInput = {
create?: Prisma.XOR<Prisma.UserCreateWithoutCheckInsInput, Prisma.UserUncheckedCreateWithoutCheckInsInput>
connectOrCreate?: Prisma.UserCreateOrConnectWithoutCheckInsInput
connect?: Prisma.UserWhereUniqueInput
}
export type UserUpdateOneRequiredWithoutCheckInsNestedInput = {
create?: Prisma.XOR<Prisma.UserCreateWithoutCheckInsInput, Prisma.UserUncheckedCreateWithoutCheckInsInput>
connectOrCreate?: Prisma.UserCreateOrConnectWithoutCheckInsInput
upsert?: Prisma.UserUpsertWithoutCheckInsInput
connect?: Prisma.UserWhereUniqueInput
update?: Prisma.XOR<Prisma.XOR<Prisma.UserUpdateToOneWithWhereWithoutCheckInsInput, Prisma.UserUpdateWithoutCheckInsInput>, Prisma.UserUncheckedUpdateWithoutCheckInsInput>
}
export type UserCreateNestedOneWithoutCheckListsInput = {
create?: Prisma.XOR<Prisma.UserCreateWithoutCheckListsInput, Prisma.UserUncheckedCreateWithoutCheckListsInput>
connectOrCreate?: Prisma.UserCreateOrConnectWithoutCheckListsInput
connect?: Prisma.UserWhereUniqueInput
}
export type UserUpdateOneRequiredWithoutCheckListsNestedInput = {
create?: Prisma.XOR<Prisma.UserCreateWithoutCheckListsInput, Prisma.UserUncheckedCreateWithoutCheckListsInput>
connectOrCreate?: Prisma.UserCreateOrConnectWithoutCheckListsInput
upsert?: Prisma.UserUpsertWithoutCheckListsInput
connect?: Prisma.UserWhereUniqueInput
update?: Prisma.XOR<Prisma.XOR<Prisma.UserUpdateToOneWithWhereWithoutCheckListsInput, Prisma.UserUpdateWithoutCheckListsInput>, Prisma.UserUncheckedUpdateWithoutCheckListsInput>
}
export type UserCreateNestedOneWithoutTemplatesInput = {
create?: Prisma.XOR<Prisma.UserCreateWithoutTemplatesInput, Prisma.UserUncheckedCreateWithoutTemplatesInput>
connectOrCreate?: Prisma.UserCreateOrConnectWithoutTemplatesInput
connect?: Prisma.UserWhereUniqueInput
}
export type UserUpdateOneWithoutTemplatesNestedInput = {
create?: Prisma.XOR<Prisma.UserCreateWithoutTemplatesInput, Prisma.UserUncheckedCreateWithoutTemplatesInput>
connectOrCreate?: Prisma.UserCreateOrConnectWithoutTemplatesInput
upsert?: Prisma.UserUpsertWithoutTemplatesInput
disconnect?: Prisma.UserWhereInput | boolean
delete?: Prisma.UserWhereInput | boolean
connect?: Prisma.UserWhereUniqueInput
update?: Prisma.XOR<Prisma.XOR<Prisma.UserUpdateToOneWithWhereWithoutTemplatesInput, Prisma.UserUpdateWithoutTemplatesInput>, Prisma.UserUncheckedUpdateWithoutTemplatesInput>
}
export type UserCreateWithoutHabitsInput = {
name: string
email: string
password: string
createdAt?: Date | string
updatedAt?: Date | string
checkIns?: Prisma.CheckInCreateNestedManyWithoutUserInput
checkLists?: Prisma.CheckListCreateNestedManyWithoutUserInput
templates?: Prisma.CheckListTemplateCreateNestedManyWithoutUserInput
}
export type UserUncheckedCreateWithoutHabitsInput = {
id?: number
name: string
email: string
password: string
createdAt?: Date | string
updatedAt?: Date | string
checkIns?: Prisma.CheckInUncheckedCreateNestedManyWithoutUserInput
checkLists?: Prisma.CheckListUncheckedCreateNestedManyWithoutUserInput
templates?: Prisma.CheckListTemplateUncheckedCreateNestedManyWithoutUserInput
}
export type UserCreateOrConnectWithoutHabitsInput = {
where: Prisma.UserWhereUniqueInput
create: Prisma.XOR<Prisma.UserCreateWithoutHabitsInput, Prisma.UserUncheckedCreateWithoutHabitsInput>
}
export type UserUpsertWithoutHabitsInput = {
update: Prisma.XOR<Prisma.UserUpdateWithoutHabitsInput, Prisma.UserUncheckedUpdateWithoutHabitsInput>
create: Prisma.XOR<Prisma.UserCreateWithoutHabitsInput, Prisma.UserUncheckedCreateWithoutHabitsInput>
where?: Prisma.UserWhereInput
}
export type UserUpdateToOneWithWhereWithoutHabitsInput = {
where?: Prisma.UserWhereInput
data: Prisma.XOR<Prisma.UserUpdateWithoutHabitsInput, Prisma.UserUncheckedUpdateWithoutHabitsInput>
}
export type UserUpdateWithoutHabitsInput = {
name?: Prisma.StringFieldUpdateOperationsInput | string
email?: Prisma.StringFieldUpdateOperationsInput | string
password?: Prisma.StringFieldUpdateOperationsInput | string
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
checkIns?: Prisma.CheckInUpdateManyWithoutUserNestedInput
checkLists?: Prisma.CheckListUpdateManyWithoutUserNestedInput
templates?: Prisma.CheckListTemplateUpdateManyWithoutUserNestedInput
}
export type UserUncheckedUpdateWithoutHabitsInput = {
id?: Prisma.IntFieldUpdateOperationsInput | number
name?: Prisma.StringFieldUpdateOperationsInput | string
email?: Prisma.StringFieldUpdateOperationsInput | string
password?: Prisma.StringFieldUpdateOperationsInput | string
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
checkIns?: Prisma.CheckInUncheckedUpdateManyWithoutUserNestedInput
checkLists?: Prisma.CheckListUncheckedUpdateManyWithoutUserNestedInput
templates?: Prisma.CheckListTemplateUncheckedUpdateManyWithoutUserNestedInput
}
export type UserCreateWithoutCheckInsInput = {
name: string
email: string
password: string
createdAt?: Date | string
updatedAt?: Date | string
habits?: Prisma.HabitCreateNestedManyWithoutUserInput
checkLists?: Prisma.CheckListCreateNestedManyWithoutUserInput
templates?: Prisma.CheckListTemplateCreateNestedManyWithoutUserInput
}
export type UserUncheckedCreateWithoutCheckInsInput = {
id?: number
name: string
email: string
password: string
createdAt?: Date | string
updatedAt?: Date | string
habits?: Prisma.HabitUncheckedCreateNestedManyWithoutUserInput
checkLists?: Prisma.CheckListUncheckedCreateNestedManyWithoutUserInput
templates?: Prisma.CheckListTemplateUncheckedCreateNestedManyWithoutUserInput
}
export type UserCreateOrConnectWithoutCheckInsInput = {
where: Prisma.UserWhereUniqueInput
create: Prisma.XOR<Prisma.UserCreateWithoutCheckInsInput, Prisma.UserUncheckedCreateWithoutCheckInsInput>
}
export type UserUpsertWithoutCheckInsInput = {
update: Prisma.XOR<Prisma.UserUpdateWithoutCheckInsInput, Prisma.UserUncheckedUpdateWithoutCheckInsInput>
create: Prisma.XOR<Prisma.UserCreateWithoutCheckInsInput, Prisma.UserUncheckedCreateWithoutCheckInsInput>
where?: Prisma.UserWhereInput
}
export type UserUpdateToOneWithWhereWithoutCheckInsInput = {
where?: Prisma.UserWhereInput
data: Prisma.XOR<Prisma.UserUpdateWithoutCheckInsInput, Prisma.UserUncheckedUpdateWithoutCheckInsInput>
}
export type UserUpdateWithoutCheckInsInput = {
name?: Prisma.StringFieldUpdateOperationsInput | string
email?: Prisma.StringFieldUpdateOperationsInput | string
password?: Prisma.StringFieldUpdateOperationsInput | string
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
habits?: Prisma.HabitUpdateManyWithoutUserNestedInput
checkLists?: Prisma.CheckListUpdateManyWithoutUserNestedInput
templates?: Prisma.CheckListTemplateUpdateManyWithoutUserNestedInput
}
export type UserUncheckedUpdateWithoutCheckInsInput = {
id?: Prisma.IntFieldUpdateOperationsInput | number
name?: Prisma.StringFieldUpdateOperationsInput | string
email?: Prisma.StringFieldUpdateOperationsInput | string
password?: Prisma.StringFieldUpdateOperationsInput | string
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
habits?: Prisma.HabitUncheckedUpdateManyWithoutUserNestedInput
checkLists?: Prisma.CheckListUncheckedUpdateManyWithoutUserNestedInput
templates?: Prisma.CheckListTemplateUncheckedUpdateManyWithoutUserNestedInput
}
export type UserCreateWithoutCheckListsInput = {
name: string
email: string
password: string
createdAt?: Date | string
updatedAt?: Date | string
habits?: Prisma.HabitCreateNestedManyWithoutUserInput
checkIns?: Prisma.CheckInCreateNestedManyWithoutUserInput
templates?: Prisma.CheckListTemplateCreateNestedManyWithoutUserInput
}
export type UserUncheckedCreateWithoutCheckListsInput = {
id?: number
name: string
email: string
password: string
createdAt?: Date | string
updatedAt?: Date | string
habits?: Prisma.HabitUncheckedCreateNestedManyWithoutUserInput
checkIns?: Prisma.CheckInUncheckedCreateNestedManyWithoutUserInput
templates?: Prisma.CheckListTemplateUncheckedCreateNestedManyWithoutUserInput
}
export type UserCreateOrConnectWithoutCheckListsInput = {
where: Prisma.UserWhereUniqueInput
create: Prisma.XOR<Prisma.UserCreateWithoutCheckListsInput, Prisma.UserUncheckedCreateWithoutCheckListsInput>
}
export type UserUpsertWithoutCheckListsInput = {
update: Prisma.XOR<Prisma.UserUpdateWithoutCheckListsInput, Prisma.UserUncheckedUpdateWithoutCheckListsInput>
create: Prisma.XOR<Prisma.UserCreateWithoutCheckListsInput, Prisma.UserUncheckedCreateWithoutCheckListsInput>
where?: Prisma.UserWhereInput
}
export type UserUpdateToOneWithWhereWithoutCheckListsInput = {
where?: Prisma.UserWhereInput
data: Prisma.XOR<Prisma.UserUpdateWithoutCheckListsInput, Prisma.UserUncheckedUpdateWithoutCheckListsInput>
}
export type UserUpdateWithoutCheckListsInput = {
name?: Prisma.StringFieldUpdateOperationsInput | string
email?: Prisma.StringFieldUpdateOperationsInput | string
password?: Prisma.StringFieldUpdateOperationsInput | string
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
habits?: Prisma.HabitUpdateManyWithoutUserNestedInput
checkIns?: Prisma.CheckInUpdateManyWithoutUserNestedInput
templates?: Prisma.CheckListTemplateUpdateManyWithoutUserNestedInput
}
export type UserUncheckedUpdateWithoutCheckListsInput = {
id?: Prisma.IntFieldUpdateOperationsInput | number
name?: Prisma.StringFieldUpdateOperationsInput | string
email?: Prisma.StringFieldUpdateOperationsInput | string
password?: Prisma.StringFieldUpdateOperationsInput | string
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
habits?: Prisma.HabitUncheckedUpdateManyWithoutUserNestedInput
checkIns?: Prisma.CheckInUncheckedUpdateManyWithoutUserNestedInput
templates?: Prisma.CheckListTemplateUncheckedUpdateManyWithoutUserNestedInput
}
export type UserCreateWithoutTemplatesInput = {
name: string
email: string
password: string
createdAt?: Date | string
updatedAt?: Date | string
habits?: Prisma.HabitCreateNestedManyWithoutUserInput
checkIns?: Prisma.CheckInCreateNestedManyWithoutUserInput
checkLists?: Prisma.CheckListCreateNestedManyWithoutUserInput
}
export type UserUncheckedCreateWithoutTemplatesInput = {
id?: number
name: string
email: string
password: string
createdAt?: Date | string
updatedAt?: Date | string
habits?: Prisma.HabitUncheckedCreateNestedManyWithoutUserInput
checkIns?: Prisma.CheckInUncheckedCreateNestedManyWithoutUserInput
checkLists?: Prisma.CheckListUncheckedCreateNestedManyWithoutUserInput
}
export type UserCreateOrConnectWithoutTemplatesInput = {
where: Prisma.UserWhereUniqueInput
create: Prisma.XOR<Prisma.UserCreateWithoutTemplatesInput, Prisma.UserUncheckedCreateWithoutTemplatesInput>
}
export type UserUpsertWithoutTemplatesInput = {
update: Prisma.XOR<Prisma.UserUpdateWithoutTemplatesInput, Prisma.UserUncheckedUpdateWithoutTemplatesInput>
create: Prisma.XOR<Prisma.UserCreateWithoutTemplatesInput, Prisma.UserUncheckedCreateWithoutTemplatesInput>
where?: Prisma.UserWhereInput
}
export type UserUpdateToOneWithWhereWithoutTemplatesInput = {
where?: Prisma.UserWhereInput
data: Prisma.XOR<Prisma.UserUpdateWithoutTemplatesInput, Prisma.UserUncheckedUpdateWithoutTemplatesInput>
}
export type UserUpdateWithoutTemplatesInput = {
name?: Prisma.StringFieldUpdateOperationsInput | string
email?: Prisma.StringFieldUpdateOperationsInput | string
password?: Prisma.StringFieldUpdateOperationsInput | string
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
habits?: Prisma.HabitUpdateManyWithoutUserNestedInput
checkIns?: Prisma.CheckInUpdateManyWithoutUserNestedInput
checkLists?: Prisma.CheckListUpdateManyWithoutUserNestedInput
}
export type UserUncheckedUpdateWithoutTemplatesInput = {
id?: Prisma.IntFieldUpdateOperationsInput | number
name?: Prisma.StringFieldUpdateOperationsInput | string
email?: Prisma.StringFieldUpdateOperationsInput | string
password?: Prisma.StringFieldUpdateOperationsInput | string
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
habits?: Prisma.HabitUncheckedUpdateManyWithoutUserNestedInput
checkIns?: Prisma.CheckInUncheckedUpdateManyWithoutUserNestedInput
checkLists?: Prisma.CheckListUncheckedUpdateManyWithoutUserNestedInput
}
/**
* Count Type UserCountOutputType
*/
export type UserCountOutputType = {
habits: number
checkIns: number
checkLists: number
templates: number
}
export type UserCountOutputTypeSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
habits?: boolean | UserCountOutputTypeCountHabitsArgs
checkIns?: boolean | UserCountOutputTypeCountCheckInsArgs
checkLists?: boolean | UserCountOutputTypeCountCheckListsArgs
templates?: boolean | UserCountOutputTypeCountTemplatesArgs
}
/**
* UserCountOutputType without action
*/
export type UserCountOutputTypeDefaultArgs<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
/**
* Select specific fields to fetch from the UserCountOutputType
*/
select?: Prisma.UserCountOutputTypeSelect<ExtArgs> | null
}
/**
* UserCountOutputType without action
*/
export type UserCountOutputTypeCountHabitsArgs<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
where?: Prisma.HabitWhereInput
}
/**
* UserCountOutputType without action
*/
export type UserCountOutputTypeCountCheckInsArgs<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
where?: Prisma.CheckInWhereInput
}
/**
* UserCountOutputType without action
*/
export type UserCountOutputTypeCountCheckListsArgs<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
where?: Prisma.CheckListWhereInput
}
/**
* UserCountOutputType without action
*/
export type UserCountOutputTypeCountTemplatesArgs<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
where?: Prisma.CheckListTemplateWhereInput
}
export type UserSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetSelect<{
@@ -400,6 +800,11 @@ export type UserSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = r
password?: boolean
createdAt?: boolean
updatedAt?: boolean
habits?: boolean | Prisma.User$habitsArgs<ExtArgs>
checkIns?: boolean | Prisma.User$checkInsArgs<ExtArgs>
checkLists?: boolean | Prisma.User$checkListsArgs<ExtArgs>
templates?: boolean | Prisma.User$templatesArgs<ExtArgs>
_count?: boolean | Prisma.UserCountOutputTypeDefaultArgs<ExtArgs>
}, ExtArgs["result"]["user"]>
@@ -414,10 +819,22 @@ export type UserSelectScalar = {
}
export type UserOmit<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetOmit<"id" | "name" | "email" | "password" | "createdAt" | "updatedAt", ExtArgs["result"]["user"]>
export type UserInclude<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
habits?: boolean | Prisma.User$habitsArgs<ExtArgs>
checkIns?: boolean | Prisma.User$checkInsArgs<ExtArgs>
checkLists?: boolean | Prisma.User$checkListsArgs<ExtArgs>
templates?: boolean | Prisma.User$templatesArgs<ExtArgs>
_count?: boolean | Prisma.UserCountOutputTypeDefaultArgs<ExtArgs>
}
export type $UserPayload<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
name: "User"
objects: {}
objects: {
habits: Prisma.$HabitPayload<ExtArgs>[]
checkIns: Prisma.$CheckInPayload<ExtArgs>[]
checkLists: Prisma.$CheckListPayload<ExtArgs>[]
templates: Prisma.$CheckListTemplatePayload<ExtArgs>[]
}
scalars: runtime.Types.Extensions.GetPayloadResult<{
id: number
name: string
@@ -765,6 +1182,10 @@ readonly fields: UserFieldRefs;
*/
export interface Prisma__UserClient<T, Null = never, ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs, GlobalOmitOptions = {}> extends Prisma.PrismaPromise<T> {
readonly [Symbol.toStringTag]: "PrismaPromise"
habits<T extends Prisma.User$habitsArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.User$habitsArgs<ExtArgs>>): Prisma.PrismaPromise<runtime.Types.Result.GetResult<Prisma.$HabitPayload<ExtArgs>, T, "findMany", GlobalOmitOptions> | Null>
checkIns<T extends Prisma.User$checkInsArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.User$checkInsArgs<ExtArgs>>): Prisma.PrismaPromise<runtime.Types.Result.GetResult<Prisma.$CheckInPayload<ExtArgs>, T, "findMany", GlobalOmitOptions> | Null>
checkLists<T extends Prisma.User$checkListsArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.User$checkListsArgs<ExtArgs>>): Prisma.PrismaPromise<runtime.Types.Result.GetResult<Prisma.$CheckListPayload<ExtArgs>, T, "findMany", GlobalOmitOptions> | Null>
templates<T extends Prisma.User$templatesArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.User$templatesArgs<ExtArgs>>): Prisma.PrismaPromise<runtime.Types.Result.GetResult<Prisma.$CheckListTemplatePayload<ExtArgs>, T, "findMany", GlobalOmitOptions> | Null>
/**
* Attaches callbacks for the resolution and/or rejection of the Promise.
* @param onfulfilled The callback to execute when the Promise is resolved.
@@ -816,6 +1237,10 @@ export type UserFindUniqueArgs<ExtArgs extends runtime.Types.Extensions.Internal
* Omit specific fields from the User
*/
omit?: Prisma.UserOmit<ExtArgs> | null
/**
* Choose, which related nodes to fetch as well
*/
include?: Prisma.UserInclude<ExtArgs> | null
/**
* Filter, which User to fetch.
*/
@@ -834,6 +1259,10 @@ export type UserFindUniqueOrThrowArgs<ExtArgs extends runtime.Types.Extensions.I
* Omit specific fields from the User
*/
omit?: Prisma.UserOmit<ExtArgs> | null
/**
* Choose, which related nodes to fetch as well
*/
include?: Prisma.UserInclude<ExtArgs> | null
/**
* Filter, which User to fetch.
*/
@@ -852,6 +1281,10 @@ export type UserFindFirstArgs<ExtArgs extends runtime.Types.Extensions.InternalA
* Omit specific fields from the User
*/
omit?: Prisma.UserOmit<ExtArgs> | null
/**
* Choose, which related nodes to fetch as well
*/
include?: Prisma.UserInclude<ExtArgs> | null
/**
* Filter, which User to fetch.
*/
@@ -900,6 +1333,10 @@ export type UserFindFirstOrThrowArgs<ExtArgs extends runtime.Types.Extensions.In
* Omit specific fields from the User
*/
omit?: Prisma.UserOmit<ExtArgs> | null
/**
* Choose, which related nodes to fetch as well
*/
include?: Prisma.UserInclude<ExtArgs> | null
/**
* Filter, which User to fetch.
*/
@@ -948,6 +1385,10 @@ export type UserFindManyArgs<ExtArgs extends runtime.Types.Extensions.InternalAr
* Omit specific fields from the User
*/
omit?: Prisma.UserOmit<ExtArgs> | null
/**
* Choose, which related nodes to fetch as well
*/
include?: Prisma.UserInclude<ExtArgs> | null
/**
* Filter, which Users to fetch.
*/
@@ -996,6 +1437,10 @@ export type UserCreateArgs<ExtArgs extends runtime.Types.Extensions.InternalArgs
* Omit specific fields from the User
*/
omit?: Prisma.UserOmit<ExtArgs> | null
/**
* Choose, which related nodes to fetch as well
*/
include?: Prisma.UserInclude<ExtArgs> | null
/**
* The data needed to create a User.
*/
@@ -1025,6 +1470,10 @@ export type UserUpdateArgs<ExtArgs extends runtime.Types.Extensions.InternalArgs
* Omit specific fields from the User
*/
omit?: Prisma.UserOmit<ExtArgs> | null
/**
* Choose, which related nodes to fetch as well
*/
include?: Prisma.UserInclude<ExtArgs> | null
/**
* The data needed to update a User.
*/
@@ -1065,6 +1514,10 @@ export type UserUpsertArgs<ExtArgs extends runtime.Types.Extensions.InternalArgs
* Omit specific fields from the User
*/
omit?: Prisma.UserOmit<ExtArgs> | null
/**
* Choose, which related nodes to fetch as well
*/
include?: Prisma.UserInclude<ExtArgs> | null
/**
* The filter to search for the User to update in case it exists.
*/
@@ -1091,6 +1544,10 @@ export type UserDeleteArgs<ExtArgs extends runtime.Types.Extensions.InternalArgs
* Omit specific fields from the User
*/
omit?: Prisma.UserOmit<ExtArgs> | null
/**
* Choose, which related nodes to fetch as well
*/
include?: Prisma.UserInclude<ExtArgs> | null
/**
* Filter which User to delete.
*/
@@ -1111,6 +1568,102 @@ export type UserDeleteManyArgs<ExtArgs extends runtime.Types.Extensions.Internal
limit?: number
}
/**
* User.habits
*/
export type User$habitsArgs<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
/**
* Select specific fields to fetch from the Habit
*/
select?: Prisma.HabitSelect<ExtArgs> | null
/**
* Omit specific fields from the Habit
*/
omit?: Prisma.HabitOmit<ExtArgs> | null
/**
* Choose, which related nodes to fetch as well
*/
include?: Prisma.HabitInclude<ExtArgs> | null
where?: Prisma.HabitWhereInput
orderBy?: Prisma.HabitOrderByWithRelationInput | Prisma.HabitOrderByWithRelationInput[]
cursor?: Prisma.HabitWhereUniqueInput
take?: number
skip?: number
distinct?: Prisma.HabitScalarFieldEnum | Prisma.HabitScalarFieldEnum[]
}
/**
* User.checkIns
*/
export type User$checkInsArgs<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
/**
* Select specific fields to fetch from the CheckIn
*/
select?: Prisma.CheckInSelect<ExtArgs> | null
/**
* Omit specific fields from the CheckIn
*/
omit?: Prisma.CheckInOmit<ExtArgs> | null
/**
* Choose, which related nodes to fetch as well
*/
include?: Prisma.CheckInInclude<ExtArgs> | null
where?: Prisma.CheckInWhereInput
orderBy?: Prisma.CheckInOrderByWithRelationInput | Prisma.CheckInOrderByWithRelationInput[]
cursor?: Prisma.CheckInWhereUniqueInput
take?: number
skip?: number
distinct?: Prisma.CheckInScalarFieldEnum | Prisma.CheckInScalarFieldEnum[]
}
/**
* User.checkLists
*/
export type User$checkListsArgs<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
/**
* Select specific fields to fetch from the CheckList
*/
select?: Prisma.CheckListSelect<ExtArgs> | null
/**
* Omit specific fields from the CheckList
*/
omit?: Prisma.CheckListOmit<ExtArgs> | null
/**
* Choose, which related nodes to fetch as well
*/
include?: Prisma.CheckListInclude<ExtArgs> | null
where?: Prisma.CheckListWhereInput
orderBy?: Prisma.CheckListOrderByWithRelationInput | Prisma.CheckListOrderByWithRelationInput[]
cursor?: Prisma.CheckListWhereUniqueInput
take?: number
skip?: number
distinct?: Prisma.CheckListScalarFieldEnum | Prisma.CheckListScalarFieldEnum[]
}
/**
* User.templates
*/
export type User$templatesArgs<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
/**
* Select specific fields to fetch from the CheckListTemplate
*/
select?: Prisma.CheckListTemplateSelect<ExtArgs> | null
/**
* Omit specific fields from the CheckListTemplate
*/
omit?: Prisma.CheckListTemplateOmit<ExtArgs> | null
/**
* Choose, which related nodes to fetch as well
*/
include?: Prisma.CheckListTemplateInclude<ExtArgs> | null
where?: Prisma.CheckListTemplateWhereInput
orderBy?: Prisma.CheckListTemplateOrderByWithRelationInput | Prisma.CheckListTemplateOrderByWithRelationInput[]
cursor?: Prisma.CheckListTemplateWhereUniqueInput
take?: number
skip?: number
distinct?: Prisma.CheckListTemplateScalarFieldEnum | Prisma.CheckListTemplateScalarFieldEnum[]
}
/**
* User without action
*/
@@ -1123,4 +1676,8 @@ export type UserDefaultArgs<ExtArgs extends runtime.Types.Extensions.InternalArg
* Omit specific fields from the User
*/
omit?: Prisma.UserOmit<ExtArgs> | null
/**
* Choose, which related nodes to fetch as well
*/
include?: Prisma.UserInclude<ExtArgs> | null
}
+13 -13
View File
@@ -1,14 +1,14 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover" />
<meta name="theme-color" content="#1f2937" />
<title>iBoard - 经济数据与策略</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover" />
<meta name="theme-color" content="#1f2937" />
<title>iBoard - 经济数据与策略</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
+1 -1
View File
@@ -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;
@@ -0,0 +1,126 @@
-- 任务模块 (V0.2): 项目 / 成员 / 看板列 / 任务 / 任务成员 / 标签
-- 设计口径见 docs/V0.2/任务模块/任务模块数据库设计文档.md
-- CreateTable
CREATE TABLE `Project` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`name` VARCHAR(50) NOT NULL,
`ownerId` INTEGER NOT NULL,
`isPersonal` BOOLEAN NOT NULL DEFAULT false,
`archived` BOOLEAN NOT NULL DEFAULT false,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
INDEX `Project_ownerId_idx`(`ownerId`),
INDEX `Project_archived_idx`(`archived`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `ProjectMember` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`projectId` INTEGER NOT NULL,
`userId` INTEGER NOT NULL,
`permission` VARCHAR(191) NOT NULL DEFAULT 'member',
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
UNIQUE INDEX `ProjectMember_projectId_userId_key`(`projectId`, `userId`),
INDEX `ProjectMember_userId_idx`(`userId`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `BoardColumn` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`projectId` INTEGER NOT NULL,
`name` VARCHAR(20) NOT NULL,
`sortOrder` INTEGER NOT NULL DEFAULT 0,
`boundStatus` VARCHAR(8) NULL,
`retention` VARCHAR(8) NOT NULL DEFAULT 'forever',
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
UNIQUE INDEX `BoardColumn_projectId_name_key`(`projectId`, `name`),
INDEX `BoardColumn_projectId_sortOrder_idx`(`projectId`, `sortOrder`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `Task` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`projectId` INTEGER NOT NULL,
`columnId` INTEGER NOT NULL,
`parentId` INTEGER NULL,
`title` VARCHAR(200) NOT NULL,
`description` TEXT NOT NULL,
`status` VARCHAR(8) NOT NULL DEFAULT 'todo',
`doneAt` DATETIME(3) NULL,
`assigneeId` INTEGER NULL,
`creatorId` INTEGER NOT NULL,
`extras` JSON NOT NULL,
`sortOrder` INTEGER NOT NULL DEFAULT 0,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
INDEX `Task_projectId_columnId_idx`(`projectId`, `columnId`),
INDEX `Task_projectId_status_idx`(`projectId`, `status`),
INDEX `Task_parentId_idx`(`parentId`),
INDEX `Task_projectId_doneAt_idx`(`projectId`, `doneAt`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `TaskMember` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`taskId` INTEGER NOT NULL,
`userId` INTEGER NOT NULL,
UNIQUE INDEX `TaskMember_taskId_userId_key`(`taskId`, `userId`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `TaskTag` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`taskId` INTEGER NOT NULL,
`name` VARCHAR(20) NOT NULL,
INDEX `TaskTag_taskId_idx`(`taskId`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- AddForeignKey
ALTER TABLE `Project` ADD CONSTRAINT `Project_ownerId_fkey` FOREIGN KEY (`ownerId`) REFERENCES `User`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `ProjectMember` ADD CONSTRAINT `ProjectMember_projectId_fkey` FOREIGN KEY (`projectId`) REFERENCES `Project`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `ProjectMember` ADD CONSTRAINT `ProjectMember_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `User`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `BoardColumn` ADD CONSTRAINT `BoardColumn_projectId_fkey` FOREIGN KEY (`projectId`) REFERENCES `Project`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `Task` ADD CONSTRAINT `Task_projectId_fkey` FOREIGN KEY (`projectId`) REFERENCES `Project`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `Task` ADD CONSTRAINT `Task_columnId_fkey` FOREIGN KEY (`columnId`) REFERENCES `BoardColumn`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `Task` ADD CONSTRAINT `Task_parentId_fkey` FOREIGN KEY (`parentId`) REFERENCES `Task`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `Task` ADD CONSTRAINT `Task_assigneeId_fkey` FOREIGN KEY (`assigneeId`) REFERENCES `User`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `Task` ADD CONSTRAINT `Task_creatorId_fkey` FOREIGN KEY (`creatorId`) REFERENCES `User`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `TaskMember` ADD CONSTRAINT `TaskMember_taskId_fkey` FOREIGN KEY (`taskId`) REFERENCES `Task`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `TaskMember` ADD CONSTRAINT `TaskMember_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `User`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `TaskTag` ADD CONSTRAINT `TaskTag_taskId_fkey` FOREIGN KEY (`taskId`) REFERENCES `Task`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,7 @@
-- 项目信息块 (T-PJ-13): Project 增加 notes JSON, 存 [{ title, content }] 任意多个长文本块
-- 分三步保证存量行安全: 先可空加列 -> 回填 '[]' -> 收紧为 NOT NULL
ALTER TABLE `Project` ADD COLUMN `notes` JSON NULL;
UPDATE `Project` SET `notes` = '[]' WHERE `notes` IS NULL;
ALTER TABLE `Project` MODIFY `notes` JSON NOT NULL;
+205
View File
@@ -19,6 +19,19 @@ model User {
password String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// V0.2 打卡 / 清单: 用户删除时级联清理
habits Habit[]
checkIns CheckIn[]
checkLists CheckList[]
templates CheckListTemplate[]
// V0.2 任务模块
ownedProjects Project[] @relation("ProjectOwner")
projectMembers ProjectMember[]
assignedTasks Task[] @relation("TaskAssignee")
createdTasks Task[] @relation("TaskCreator")
taskMembers TaskMember[]
}
model Article {
@@ -281,4 +294,196 @@ 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])
}
// ================== 任务模块 (V0.2 任务模块需求文档 T-*) ==================
// 口径见 docs/V0.2/任务模块/任务模块数据库设计文档.md
// 项目 = 隔离 + 权限单位; 看板列是实体(可增删改排序);
// 任务状态(todo/doing/done) 与 看板列 是两个正交维度, 由列的 boundStatus 决定是否联动.
model Project {
id Int @id @default(autoincrement())
name String @db.VarChar(50)
ownerId Int
owner User @relation("ProjectOwner", fields: [ownerId], references: [id], onDelete: Cascade)
isPersonal Boolean @default(false)
archived Boolean @default(false)
notes Json @default("[]") // 项目信息块 [{ title, content }] (T-PJ-13)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
members ProjectMember[]
columns BoardColumn[]
tasks Task[]
@@index([ownerId])
@@index([archived])
}
model ProjectMember {
id Int @id @default(autoincrement())
projectId Int
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
userId Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
permission String @default("member")
createdAt DateTime @default(now())
@@unique([projectId, userId])
@@index([userId])
}
model BoardColumn {
id Int @id @default(autoincrement())
projectId Int
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
name String @db.VarChar(20)
sortOrder Int @default(0)
boundStatus String? @db.VarChar(8)
retention String @default("forever") @db.VarChar(8)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
tasks Task[]
@@unique([projectId, name])
@@index([projectId, sortOrder])
}
model Task {
id Int @id @default(autoincrement())
projectId Int
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
columnId Int
column BoardColumn @relation(fields: [columnId], references: [id], onDelete: Restrict)
parentId Int?
parent Task? @relation("Subtask", fields: [parentId], references: [id], onDelete: Cascade)
children Task[] @relation("Subtask")
title String @db.VarChar(200)
description String @default("") @db.Text
status String @default("todo") @db.VarChar(8)
doneAt DateTime?
assigneeId Int?
assignee User? @relation("TaskAssignee", fields: [assigneeId], references: [id], onDelete: SetNull)
creatorId Int
creator User @relation("TaskCreator", fields: [creatorId], references: [id], onDelete: Cascade)
extras Json @default("[]")
sortOrder Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
members TaskMember[]
tags TaskTag[]
@@index([projectId, columnId])
@@index([projectId, status])
@@index([parentId])
@@index([projectId, doneAt])
}
model TaskMember {
id Int @id @default(autoincrement())
taskId Int
task Task @relation(fields: [taskId], references: [id], onDelete: Cascade)
userId Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([taskId, userId])
}
model TaskTag {
id Int @id @default(autoincrement())
taskId Int
task Task @relation(fields: [taskId], references: [id], onDelete: Cascade)
name String @db.VarChar(20)
@@index([taskId])
}
+124 -5
View File
@@ -1,22 +1,126 @@
# iBoard
用于收集和整理经济数据,但是以策略为主体重新组织。
## 版本管理 (V0.1)
## 分支规则
版本号遵循 `主版本.次版本.修订号`,**唯一手动管理点是 `package.json` 的 `version` 字段**(当前 `0.1.0`)。
发版时只改这一处,以下位置必须保持一致(改版本时一并核对):
### 分支类型与职责
| 分支 | 职责 | 部署 | 合并方向 |
| --- | --- | --- | --- |
| `master` | **主分支**(生产分支) | ✅ 唯一部署出口,push 即发布生产 | — |
| `dev-*` | **开发集成分支**,如 `dev-0.2` | ❌ 不部署(只跑 CI 校验) | 合到 `master` 后由 master 部署 |
| `tmp-*` | **临时分支**,针对单个问题的修改 | ❌ 不部署 | 合到对应 `dev-*` 后删除 |
| `android-*` | **Android 外壳构建分支**,如 `android-1.0` | ❌ 不部署,只触发 APK 构建 | 构建前须与 `master` 同步 |
> **关于 `main`**:团队约定里的「main 主分支」在本仓库落地为 **`master`**(仓库默认分支即 `master`,不另建 `main`)。
> 下文所有「main 主分支」均指 `master`。
### 流程
```
tmp-xxx ──合并──▶ dev-0.2 ──合并──▶ master ──push──▶ 自动部署生产
│ │
│ └──push──▶ 只跑 CI(版本一致性校验),不部署
│
└── 问题解决后删除
android-1.0 ── 从 master 同步代码后 push ──▶ 只触发 APK 构建 / 发布到 Packages
```
1. **日常开发**:从 `dev-*` 拉 `tmp-*`,改完合回 `dev-*`,`tmp-*` 用完即删。
2. **上线**:`dev-*` 验收通过后合并到 `master`,**推送 `master` 才部署生产**。
3. **打 Android 包**:`android-*` 与 `master` 同步(`git merge master` 或 `git reset --hard origin/master`)后推送,
触发 [android-build.yml](.gitea/workflows/android-build.yml),产物发布到 Gitea Generic Packages。
### CI 触发范围
| 工作流 | 触发分支 | 动作 |
| --- | --- | --- |
| [docker-build.yml](.gitea/workflows/docker-build.yml) | **仅 `master`** | 构建前端 + 生成签名 OTA 包 + 构建镜像 + 迁移数据库 + 部署生产 |
| [android-build.yml](.gitea/workflows/android-build.yml) | `android-*` | 构建并签名 release APK,上传到 Packages |
| [ota-build.yml](.gitea/workflows/ota-build.yml) | 仅手动 `workflow_dispatch` | 单独构建/签发 OTA 包 |
| [sync-registry.yml](.gitea/workflows/sync-registry.yml) | 见文件 | 同步注册中心 |
> ⚠️ **重要变更(2026-10)**:`dev-*` 分支**已取消** push 自动部署。
> 此前 `dev-*` 与 `master` 共用同一套生产环境,推送开发分支等于直接发布,容易把半成品带上线。
> 现在开发分支的提交只有合并进 `master` 并推送后才会部署。
## 版本管理
### 一句话规则
**同一个版本号,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 | 行为 |
| --- | --- | --- |
| `master`(主分支) | `docker-build.yml` | 生产发布:构建前端 → 生成签名 OTA 包 → 打 Docker 镜像 → 数据库自动升级 → 重启容器 `iboard` (宿主 3001) |
| `dev-*` | **不触发部署** | 推送只走版本一致性校验;上线须先合并到 `master` |
| `tmp-*` | 不触发 | 临时分支,合到 `dev-*` 后删除 |
| `android-*` | `android-build.yml` | 打签名 release APK 并上传到 Gitea Generic Package Registry(构建前须与 `master` 同步) |
| 手动 | `ota-build.yml` / `sync-registry.yml` | 单独出 OTA 包 / 同步 secrets 到注册中心 |
数据库升级约定:
- 唯一入口是 `prisma/migrations/` 下的迁移文件,由 `npx prisma migrate deploy` 在**启新容器之前**执行
(幂等,无新迁移时为 no-op);迁移失败会中断 workflow,不会用新镜像顶掉仍在运行的旧容器。
- 由于只有 `master` 会部署,**迁移真正作用到生产库的唯一时机就是 master 发布**;
开发分支上的迁移不会被自动应用,请在合并到 `master` 前确认可回滚。
- 本地改了 `prisma/schema.prisma` 后:写迁移 SQL → `npx prisma migrate deploy` 验证 → 提交迁移目录。
- 首页主要是放经济文章
- 数据主要是放各种经济数据和图表。
- 策略是放按照经济理论组织的相关数据和图表的对比。
@@ -105,6 +209,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, 仅显示"有新版本请刷新".
+115
View File
@@ -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] 全部通过')
+5 -5
View File
@@ -21,15 +21,15 @@ for (;;) {
console.log('[米家] 扫码失败: ' + (s.lastError || '未知'))
process.exit(2)
}
if (s.state === 'idle' && s.running === false && !s.qrImageUrl) {
if (s.done) {
console.log('[米家] 扫码成功, 凭证已保存到 server/mihome/.cache/')
process.exit(0)
}
if (!s.running && !s.qrImageUrl) {
// 启动即失败(可能直接完成/或没权限)
console.log('[米家] 流程结束(无二维码产生), 请检查日志')
process.exit(2)
}
if (!s.running && s.qrImageUrl) {
console.log('[米家] 扫码成功, 凭证已保存到 server/mihome/.cache/')
process.exit(0)
}
if (Date.now() - started > 150000) {
console.log('[米家] 超时, 已停止; 可重跑.')
process.exit(3)
+120
View File
@@ -0,0 +1,120 @@
// 打卡周期计算: 全部以 Asia/Shanghai 的"本地日历日"为准
// 日期统一用 "YYYY-MM-DD" 字符串 (字典序 = 时间序, 可直接用于范围查询)
export const TZ = 'Asia/Shanghai'
const FMT = new Intl.DateTimeFormat('en-CA', {
timeZone: TZ,
year: 'numeric',
month: '2-digit',
day: '2-digit',
})
/** 任意时间 -> 本地日历日 "YYYY-MM-DD" */
export function toDateStr(d = new Date()) {
return FMT.format(d instanceof Date ? d : new Date(d))
}
/** "YYYY-MM-DD" -> UTC 零点的 Date (仅用于日期运算, 不做时区展示) */
function toUTC(s) {
const [y, m, d] = String(s).split('-').map(Number)
return new Date(Date.UTC(y, (m || 1) - 1, d || 1))
}
function fromUTC(dt) {
return dt.toISOString().slice(0, 10)
}
export function addDays(s, n) {
const d = toUTC(s)
d.setUTCDate(d.getUTCDate() + n)
return fromUTC(d)
}
export function addMonths(s, n) {
const [y, m] = String(s).split('-').map(Number)
const first = new Date(Date.UTC(y, (m || 1) - 1 + n, 1))
const day = Number(String(s).slice(8, 10)) || 1
const lastDay = new Date(Date.UTC(first.getUTCFullYear(), first.getUTCMonth() + 1, 0)).getUTCDate()
first.setUTCDate(Math.min(day, lastDay))
return fromUTC(first)
}
/** 周一为一周第一天 */
export function startOfWeek(s) {
const dow = (toUTC(s).getUTCDay() + 6) % 7 // 0=周一
return addDays(s, -dow)
}
export function endOfWeek(s) {
return addDays(startOfWeek(s), 6)
}
export function startOfMonth(s) {
return String(s).slice(0, 8) + '01'
}
export function endOfMonth(s) {
const [y, m] = String(s).split('-').map(Number)
return fromUTC(new Date(Date.UTC(y, m || 1, 0)))
}
export function startOfYear(s) {
return String(s).slice(0, 4) + '-01-01'
}
export function endOfYear(s) {
return String(s).slice(0, 4) + '-12-31'
}
export const PERIODS = ['day', 'week', 'month', 'year']
export const PERIOD_LABEL = { day: '每天', week: '每周', month: '每月', year: '每年' }
/** 周期窗口 [start, end] (闭区间, 本地日历日) */
export function periodRange(period, s) {
switch (period) {
case 'week': return { start: startOfWeek(s), end: endOfWeek(s) }
case 'month': return { start: startOfMonth(s), end: endOfMonth(s) }
case 'year': return { start: startOfYear(s), end: endOfYear(s) }
default: return { start: s, end: s }
}
}
/** 周期窗口内的天数 */
export function periodDays(period, s) {
const { start, end } = periodRange(period, s)
return Math.round((toUTC(end) - toUTC(start)) / 86400000) + 1
}
/**
* 日均期望次数: 用于日历上"某一天是否完成了当天应做的份额"
* - 每天 n 次 -> n
* - 每周 n 次 -> ceil(n / 7)
* - 每月 n 次 -> ceil(n / 当月天数)
* - 每年 n 次 -> ceil(n / 365)
* 环形进度仍以"周期完成度"(周期内累计次数 / n) 为准, 两者不冲突
*/
export function expectedPerDay(period, target, s) {
const n = Math.max(1, Number(target) || 1)
if (period === 'week') return Math.max(1, Math.ceil(n / 7))
if (period === 'month') return Math.max(1, Math.ceil(n / periodDays('month', s)))
if (period === 'year') return Math.max(1, Math.ceil(n / 365))
return n
}
/** 该周期在日期 d 之前是否已完整结束 (用于 streak 判断: 未结束的周期不算断签) */
export function periodIsClosed(period, d, today) {
const { end } = periodRange(period, d)
return end < today
}
/** 遍历 [start, end] 闭区间的每一天 */
export function eachDay(start, end, fn) {
let cur = start
let guard = 0
while (cur <= end && guard++ < 4000) {
fn(cur)
cur = addDays(cur, 1)
}
}
+11
View File
@@ -12,6 +12,10 @@ 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'
import projectsRouter, { columnRouter } from './routes/projects.js'
import tasksRouter from './routes/tasks.js'
const PORT = Number(process.env.PORT) || 3001
const DIST_DIR = path.join(process.cwd(), 'dist')
@@ -36,6 +40,13 @@ 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)
// V0.2 任务模块: 项目 / 列 / 任务
app.use('/api/projects', projectsRouter)
app.use('/api/columns', columnRouter)
app.use('/api', tasksRouter)
// 静态资源 (Vite build 产物)
if (HAS_DIST) {
+77 -39
View File
@@ -20,8 +20,11 @@ export function getApi() {
return _api
}
// 认证状态内存缓存 (5 分钟 TTL, /status 不每次打米家账号服务)
// 认证状态内存缓存 (成功缓存 5 分钟; 失败只缓存 30 秒, 网络/服务恢复后能尽快自愈)
const TTL_OK = 5 * 60 * 1000
const TTL_BAD = 30 * 1000
let authCache = { status: 'unknown', lastError: '', checkedAt: 0 }
let probing = null // 并发合并: 页面首次渲染时 force 探测 + 可见性刷新可能同时进来
function persistAccount(status, lastError) {
return prisma.mihomeAccount
@@ -35,31 +38,49 @@ function persistAccount(status, lastError) {
})
}
// 校验米家是否可用: 有凭证 + 能刷新 token (刷新会自动处理 30 天过期)
// 校验米家是否可用: 有凭证 + 真打一次云接口确认 token 没失效
// force=true 忽略 TTL 强查
//
// 注意: 不能用 api.refreshToken() 当探针. 该库在本地凭证字段齐全时直接返回
// { tokenRefreshed: false } (见 @zythum02/mijia-api 的 available getter),
// 根本不发请求 —— 于是 30 天 token 过期后它永远返回成功, 前端就永远看不到
// 「授权已过期」横幅, 也就不会弹扫码. 必须发一次真实业务请求才算验证.
export async function checkAuth(force = false) {
const now = Date.now()
if (!force && now - authCache.checkedAt < 5 * 60 * 1000) return authCache
const api = getApi()
let status = 'expired'
let lastError = ''
const ttl = authCache.status === 'ok' ? TTL_OK : TTL_BAD
if (!force && now - authCache.checkedAt < ttl) return authCache
if (probing) return probing
probing = (async () => {
const api = getApi()
let status = 'expired'
let lastError = ''
try {
if (!api.available) throw new Error('无授权凭证')
await api.refreshToken() // 凭证真过期时这里会自动走 passToken 刷新
await api.getHomesList() // 真正打到米家云, token 失效会抛 APIError
status = 'ok'
} catch (e) {
status = 'expired'
lastError = e?.message || String(e)
}
const changed = authCache.status !== status || authCache.lastError !== lastError
authCache = { status, lastError, checkedAt: Date.now() }
// 状态没变就不写库, 避免 30 秒重试把 DB 刷爆
if (changed) await persistAccount(status, lastError)
return authCache
})()
try {
if (!api.available) throw new Error('无授权凭证')
await api.refreshToken()
status = 'ok'
} catch (e) {
status = 'expired'
lastError = e?.message || String(e)
return await probing
} finally {
probing = null
}
authCache = { status, lastError, checkedAt: now }
await persistAccount(status, lastError)
return authCache
}
// ==================== 扫码重授权流程 (模块级单例) ====================
// 状态: idle | waiting(已出二维码等扫码) | failed
// state: idle | waiting(已出二维码等扫码) | failed
// done: 只有真正登录成功才置 true, 前端靠它判断「轮询结束 = 授权成功」
// 扫码成功后 QRlogin 内部已保存凭证, 这里只置状态并回写 DB
const qrState = { running: false, canceled: false, state: 'idle', qrImageUrl: '', lastError: '', startedAt: 0 }
const qrState = { running: false, done: false, state: 'idle', qrImageUrl: '', lastError: '', startedAt: 0 }
export function getQrState() {
return { ...qrState }
@@ -70,29 +91,50 @@ export function getQrImageUrl() {
}
export async function startQrLogin() {
if (qrState.running) return getQrState()
// 已有一轮在跑就直接复用: 同一张二维码继续有效, 不重复开.
// 注意: 这里绝不能提前 return 而不清 done —— 上一轮成功时 done=true 会残留,
// 前端第一次轮询就拿到 {done:true, running:false}, 立刻误判成"扫码成功"关掉弹窗.
if (qrState.running) {
const snap = getQrState()
return { ...snap, done: false, running: true }
}
const api = getApi()
qrState.running = true
qrState.canceled = false
qrState.done = false
qrState.state = 'idle'
qrState.lastError = ''
qrState.qrImageUrl = ''
qrState.startedAt = Date.now()
;(async () => {
// 二维码有效期 120s (库内长轮询 AbortSignal.timeout), 超时就重开一张,
// 否则用户扫码慢一点只会看到「扫码流程失败」, 还得手动点刷新
const MAX_RETRY = 2
try {
await api.QRlogin(async (info) => {
qrState.qrImageUrl = info.qrImageUrl || info.loginUrl || ''
qrState.state = 'waiting'
})
if (qrState.canceled) return
qrState.state = 'idle'
authCache = { status: 'ok', lastError: '', checkedAt: Date.now() }
await persistAccount('ok', '')
} catch (e) {
if (qrState.canceled) {
qrState.state = 'idle'
} else {
qrState.state = 'failed'
qrState.lastError = e?.message || String(e)
for (let attempt = 0; ; attempt++) {
try {
await api.QRlogin(async (info) => {
qrState.qrImageUrl = info.qrImageUrl || info.loginUrl || ''
qrState.state = 'waiting'
})
// 这里不要因为「用户点了取消」就丢弃结果: 弹窗关了但二维码可能已经被扫,
// 凭证库内部已落盘, 丢掉会让前端继续显示已过期
qrState.state = 'idle'
qrState.done = true
authCache = { status: 'ok', lastError: '', checkedAt: Date.now() }
await persistAccount('ok', '')
return
} catch (e) {
const msg = e?.message || String(e)
const timedOut = /abort|timeout|timed out/i.test(msg)
if (timedOut && attempt < MAX_RETRY) {
qrState.qrImageUrl = ''
qrState.state = 'idle'
continue
}
qrState.state = 'failed'
qrState.lastError = msg
return
}
}
} finally {
qrState.running = false
@@ -102,11 +144,7 @@ export async function startQrLogin() {
}
export function cancelQrLogin() {
qrState.canceled = true
if (qrState.running) {
// 后台 QRlogin 仍在长轮询, 即便取消也让它自然结束, 只标记不再使用结果
} else if (qrState.state !== 'failed') {
qrState.state = 'idle'
}
// 只影响前端是否继续轮询; 后台长轮询没法中断, 让它自然结束 (扫了也照样算成功)
if (!qrState.running && qrState.state !== 'failed') qrState.state = 'idle'
return getQrState()
}
+8 -4
View File
@@ -10,8 +10,9 @@ export { getDevice, todayBuckets, monthBuckets, yearBuckets, deviceOverview } fr
export { getSpec, ENV_MODELS } from './specs.js'
// GET /api/mihome/status 聚合: 授权 + 家庭(设备) + 统计可用性 + 每设备概览(电量/最新/缺日)
export async function getStatus() {
const authState = await auth.checkAuth()
// force=true 跳过 5 分钟认证缓存强查一次 (用户主动打开页面/刷新时用)
export async function getStatus(force = false) {
const authState = await auth.checkAuth(force)
const [homes, devices, account] = await Promise.all([
prisma.mihomeHome.findMany({ orderBy: { id: 'asc' } }),
prisma.mihomeDevice.findMany({ orderBy: { id: 'asc' } }),
@@ -56,10 +57,13 @@ export async function getStatus() {
})),
}))
// 以实时探测结果为准: DB 里的 status 只在实时探测还是 unknown 时兜底,
// 否则过期后残留的 ok 会把「已失效」遮掉, 前端永远不出重新授权横幅.
const liveUnknown = !authState.status || authState.status === 'unknown'
return {
auth: {
status: account?.status || authState.status,
lastError: account?.lastError || authState.lastError,
status: liveUnknown ? account?.status || 'unknown' : authState.status,
lastError: (liveUnknown ? account?.lastError : authState.lastError) || '',
checkedAt: authState.checkedAt,
},
capability,
+328
View File
@@ -0,0 +1,328 @@
import { Router } from 'express'
import { prisma } from '../db.js'
import { authRequired } from '../middleware.js'
import {
toDateStr, addDays, periodRange, periodDays, expectedPerDay,
periodIsClosed, eachDay, PERIODS, PERIOD_LABEL,
} from '../checkin/period.js'
const router = Router()
router.use(authRequired)
// 补打卡允许的回溯天数 (F-CK-10)
const BACKFILL_DAYS = 7
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/
const MONTH_RE = /^\d{4}-\d{2}$/
function normDate(v) {
const s = String(v || '')
return DATE_RE.test(s) ? s : toDateStr()
}
// ---------- 统计辅助 ----------
// 记录 -> { habitId: { date: count } }
function groupRecords(records) {
const map = new Map()
for (const r of records) {
if (!map.has(r.habitId)) map.set(r.habitId, new Map())
const byDate = map.get(r.habitId)
byDate.set(r.date, (byDate.get(r.date) || 0) + 1)
}
return map
}
function countInRange(byDate, start, end) {
let n = 0
eachDay(start, end, (d) => { n += byDate.get(d) || 0 })
return n
}
// 连续达标的周期数: 当前周期未结束且未达标 -> 跳过(不算断签), 不是中断
function computeStreak(habit, byDate, today) {
let cursor = today
let streak = 0
for (let i = 0; i < 400; i++) {
const { start } = periodRange(habit.period, cursor)
const done = countInRange(byDate, start, periodRange(habit.period, cursor).end)
if (done >= habit.target) {
streak++
} else if (periodIsClosed(habit.period, cursor, today)) {
break
}
const prevEnd = addDays(start, -1)
if (prevEnd < '1970-01-01') break
cursor = prevEnd
}
return streak
}
async function loadActiveHabits(userId) {
return prisma.habit.findMany({
where: { userId, archived: false },
orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }],
})
}
// 组装单个打卡项的展示态
function buildItem(habit, byDate, date, today) {
const { start, end } = periodRange(habit.period, date)
const done = countInRange(byDate, start, end)
const target = Math.max(1, habit.target)
const onDate = byDate.get(date) || 0
const expPerDay = expectedPerDay(habit.period, target, date)
return {
id: habit.id,
name: habit.name,
period: habit.period,
periodLabel: PERIOD_LABEL[habit.period] || '每天',
target,
color: habit.color,
icon: habit.icon,
note: habit.note,
archived: habit.archived,
periodStart: start,
periodEnd: end,
periodDays: periodDays(habit.period, date),
done,
remaining: Math.max(0, target - done),
percent: Math.min(1, target > 0 ? done / target : 1),
completed: done >= target,
over: Math.max(0, done - target),
countOnDate: onDate,
expectedPerDay: expPerDay,
dayDone: onDate >= expPerDay,
streak: computeStreak(habit, byDate, today),
}
}
// 拉取 [start, end] 内的打卡记录并分组
async function loadRecords(userId, habits, ranges) {
let min = ranges[0]?.start
let max = ranges[0]?.end
for (const r of ranges) {
if (r.start < min) min = r.start
if (r.end > max) max = r.end
}
if (!min || !max) return new Map()
const records = await prisma.checkIn.findMany({
where: { userId, habitId: { in: habits.map((h) => h.id) }, date: { gte: min, lte: max } },
select: { habitId: true, date: true },
})
return groupRecords(records)
}
// ---------- 打卡项 ----------
// GET /api/checkin/habits?date=YYYY-MM-DD 今日(或指定日)待办 + 周期进度
router.get('/habits', async (req, res, next) => {
try {
const userId = req.user.sub
const today = toDateStr()
const date = normDate(req.query.date)
const habits = await loadActiveHabits(userId)
const ranges = habits.map((h) => periodRange(h.period, date))
const grouped = await loadRecords(userId, habits, ranges.length ? ranges : [{ start: date, end: date }])
const items = habits.map((h) => buildItem(h, grouped.get(h.id) || new Map(), date, today))
const doneCount = items.filter((i) => i.completed).length
res.json({ today, date, items, summary: { total: items.length, done: doneCount } })
} catch (e) { next(e) }
})
// POST /api/checkin/habits 新建打卡项
router.post('/habits', async (req, res, next) => {
try {
const userId = req.user.sub
const { name, period = 'day', target = 1, color = '#1677ff', icon = '', note = '' } = req.body || {}
const cleanName = String(name || '').trim()
if (!cleanName) return res.status(400).json({ error: '名称不能为空' })
if (cleanName.length > 20) return res.status(400).json({ error: '名称不能超过 20 个字' })
if (!PERIODS.includes(period)) return res.status(400).json({ error: '周期类型不合法' })
const t = Math.floor(Number(target))
if (!Number.isFinite(t) || t < 1) return res.status(400).json({ error: '目标次数需为 ≥1 的整数' })
const row = await prisma.habit.create({
data: { userId, name: cleanName, period, target: t, color: String(color), icon: String(icon), note: String(note || '') },
})
res.json({ item: row })
} catch (e) { next(e) }
})
// PUT /api/checkin/habits/:id 编辑
router.put('/habits/:id', async (req, res, next) => {
try {
const userId = req.user.sub
const id = Number(req.params.id)
const exist = await prisma.habit.findFirst({ where: { id, userId } })
if (!exist) return res.status(404).json({ error: '打卡项不存在' })
const { name, period, target, color, icon, note, archived, sortOrder } = req.body || {}
const data = {}
if (name !== undefined) {
const n = String(name).trim()
if (!n) return res.status(400).json({ error: '名称不能为空' })
if (n.length > 20) return res.status(400).json({ error: '名称不能超过 20 个字' })
data.name = n
}
if (period !== undefined) {
if (!PERIODS.includes(period)) return res.status(400).json({ error: '周期类型不合法' })
data.period = period
}
if (target !== undefined) {
const t = Math.floor(Number(target))
if (!Number.isFinite(t) || t < 1) return res.status(400).json({ error: '目标次数需为 ≥1 的整数' })
data.target = t
}
if (color !== undefined) data.color = String(color)
if (icon !== undefined) data.icon = String(icon)
if (note !== undefined) data.note = String(note)
if (archived !== undefined) data.archived = !!archived
if (sortOrder !== undefined) data.sortOrder = Number(sortOrder) || 0
const row = await prisma.habit.update({ where: { id }, data })
res.json({ item: row })
} catch (e) { next(e) }
})
// DELETE /api/checkin/habits/:id?hard=1 默认归档(保留历史), hard=1 物理删除
router.delete('/habits/:id', async (req, res, next) => {
try {
const userId = req.user.sub
const id = Number(req.params.id)
const exist = await prisma.habit.findFirst({ where: { id, userId } })
if (!exist) return res.status(404).json({ error: '打卡项不存在' })
if (req.query.hard === '1') {
await prisma.checkIn.deleteMany({ where: { habitId: id } })
await prisma.habit.delete({ where: { id } })
return res.json({ ok: true, removed: true })
}
const row = await prisma.habit.update({ where: { id }, data: { archived: true } })
res.json({ ok: true, removed: false, item: row })
} catch (e) { next(e) }
})
// ---------- 打卡 / 撤销 ----------
// POST /api/checkin/habits/:id/check { date?, note? } 打一次卡
router.post('/habits/:id/check', async (req, res, next) => {
try {
const userId = req.user.sub
const id = Number(req.params.id)
const habit = await prisma.habit.findFirst({ where: { id, userId } })
if (!habit) return res.status(404).json({ error: '打卡项不存在' })
const today = toDateStr()
const date = normDate((req.body || {}).date)
if (date > today) return res.status(400).json({ error: '不能为未来日期打卡' })
if (date < addDays(today, -BACKFILL_DAYS)) {
return res.status(400).json({ error: `最多只能补打最近 ${BACKFILL_DAYS} 天` })
}
const dayRecords = await prisma.checkIn.findMany({ where: { habitId: id, date }, select: { seq: true } })
const seq = dayRecords.reduce((m, r) => Math.max(m, r.seq), 0) + 1
const note = String((req.body || {}).note || '').slice(0, 200)
await prisma.checkIn.create({
data: { habitId: id, userId, date, seq, note, source: date === today ? 'user' : 'backfill' },
})
const grouped = await loadRecords(userId, [habit], [periodRange(habit.period, date)])
res.json({ item: buildItem(habit, grouped.get(habit.id) || new Map(), date, today), date })
} catch (e) {
// 唯一约束冲突 = 并发重复提交, 视为已打卡
if (e && e.code === 'P2002') return res.status(409).json({ error: '该次打卡已记录' })
next(e)
}
})
// DELETE /api/checkin/habits/:id/check?date= 撤销本周期内最近一次打卡
router.delete('/habits/:id/check', async (req, res, next) => {
try {
const userId = req.user.sub
const id = Number(req.params.id)
const habit = await prisma.habit.findFirst({ where: { id, userId } })
if (!habit) return res.status(404).json({ error: '打卡项不存在' })
const today = toDateStr()
const date = normDate(req.query.date)
const { start, end } = periodRange(habit.period, date)
const last = await prisma.checkIn.findFirst({
where: { habitId: id, date: { gte: start, lte: end } },
orderBy: [{ date: 'desc' }, { seq: 'desc' }],
})
if (!last) return res.status(400).json({ error: '本周期还没有打卡记录' })
await prisma.checkIn.delete({ where: { id: last.id } })
const grouped = await loadRecords(userId, [habit], [{ start, end }])
res.json({ item: buildItem(habit, grouped.get(habit.id) || new Map(), date, today), date })
} catch (e) { next(e) }
})
// ---------- 某日详情 / 月历 ----------
// GET /api/checkin/day?date=YYYY-MM-DD 当天每个打卡项的明细 (含可打卡/只读标记)
router.get('/day', async (req, res, next) => {
try {
const userId = req.user.sub
const today = toDateStr()
const date = normDate(req.query.date)
const habits = await loadActiveHabits(userId)
const ranges = habits.map((h) => periodRange(h.period, date))
const grouped = await loadRecords(userId, habits, ranges.length ? ranges : [{ start: date, end: date }])
const items = habits.map((h) => {
const base = buildItem(h, grouped.get(h.id) || new Map(), date, today)
return { ...base, canCheck: date <= today && date >= addDays(today, -BACKFILL_DAYS) }
})
res.json({ today, date, isToday: date === today, isFuture: date > today, items })
} catch (e) { next(e) }
})
// GET /api/checkin/calendar?month=YYYY-MM 月历: 每天完成情况 + 当月完成率
router.get('/calendar', async (req, res, next) => {
try {
const userId = req.user.sub
const today = toDateStr()
const month = MONTH_RE.test(String(req.query.month || '')) ? String(req.query.month) : today.slice(0, 7)
const start = month + '-01'
const end = addDays(monthStartNext(month), -1)
const habits = await prisma.habit.findMany({ where: { userId }, orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }] })
const active = habits.filter((h) => !h.archived)
const records = await prisma.checkIn.findMany({
where: { userId, date: { gte: start, lte: end } },
select: { habitId: true, date: true },
})
const grouped = groupRecords(records)
const days = []
let expTotal = 0
let doneTotal = 0
eachDay(start, end, (d) => {
let expected = 0
let actual = 0
let over = false
const detail = []
for (const h of active) {
if (toDateStr(h.createdAt) > d) continue // 创建之前的日期不计入期望
const exp = expectedPerDay(h.period, Math.max(1, h.target), d)
const cnt = (grouped.get(h.id) || new Map()).get(d) || 0
expected += exp
actual += Math.min(cnt, exp)
if (cnt > exp) over = true
detail.push({ id: h.id, name: h.name, color: h.color, count: cnt, expected: exp, done: cnt >= exp })
}
const isFuture = d > today
if (!isFuture) { expTotal += expected; doneTotal += actual }
let status = 'empty'
if (expected > 0 && actual >= expected) status = over ? 'over' : 'full'
else if (actual > 0) status = 'partial'
days.push({
date: d, expected, actual, status, isToday: d === today, isFuture,
habits: detail,
})
})
res.json({
today, month, days,
rate: expTotal > 0 ? Math.min(1, doneTotal / expTotal) : 0,
habits: active.map((h) => ({ id: h.id, name: h.name, color: h.color, period: h.period, target: h.target })),
})
} catch (e) { next(e) }
})
function monthStartNext(month) {
const [y, m] = month.split('-').map(Number)
const d = new Date(Date.UTC(y, m, 1)) // m 已 +1 (下月)
return d.toISOString().slice(0, 10)
}
export default router
+356
View File
@@ -0,0 +1,356 @@
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,
}
}
// ---------- 模板 ----------
// 模板按名称字母顺序 (中文按拼音) 排序, 前后端共用同一比较器语义
const NAME_COLLATOR = new Intl.Collator('zh-Hans-CN', { numeric: true, sensitivity: 'base' })
const byName = (a, b) => NAME_COLLATOR.compare(String(a.name || ''), String(b.name || ''))
// 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 }] },
})
res.json({ items: rows.sort(byName) })
} catch (e) { next(e) }
})
// GET /api/checklists/templates/:id 模板详情
router.get('/templates/:id', async (req, res, next) => {
try {
const row = await prisma.checkListTemplate.findFirst({
where: { id: Number(req.params.id), OR: [{ builtin: true }, { userId: req.user.sub }] },
})
if (!row) return res.status(404).json({ error: '模板不存在' })
res.json({ item: row })
} 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: '模板名称不能为空' })
if (cleanName.length > 20) return res.status(400).json({ error: '模板名称不能超过 20 个字' })
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) }
})
// PUT /api/checklists/templates/:id 改名称 / 检查项 (系统预置模板不可改)
router.put('/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, builtin: false } })
if (!exist) return res.status(404).json({ error: '模板不存在、无权限修改或是系统预置模板' })
const { name, scene, items } = 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 (scene !== undefined) data.scene = String(scene)
if (items !== undefined) {
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: '模板至少需要一个检查项' })
data.items = list
}
const row = await prisma.checkListTemplate.update({ where: { id }, data })
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, builtin: false } })
if (!exist) return res.status(404).json({ error: '模板不存在或无权删除' })
await prisma.checkListTemplate.delete({ where: { id } })
res.json({ ok: true })
} catch (e) { next(e) }
})
// ---------- 清单 ----------
// GET /api/checklists?archived=0|1
router.get('/', async (req, res, next) => {
try {
const userId = req.user.sub
const archived = String(req.query.archived || '0') === '1'
const rows = await prisma.checkList.findMany({
where: { userId, archived },
include: LIST_INCLUDE,
orderBy: [{ updatedAt: 'desc' }],
})
res.json({ items: rows.map(withProgress) })
} catch (e) { next(e) }
})
// POST /api/checklists { title, scene?, note?, items?: string[] | [{title,note}], templateId? }
router.post('/', async (req, res, next) => {
try {
const userId = req.user.sub
const { title, scene = '', note = '', items = [], templateId } = req.body || {}
const cleanTitle = String(title || '').trim()
if (!cleanTitle) return res.status(400).json({ error: '清单名称不能为空' })
let seed = (Array.isArray(items) ? items : [])
.map((i) => (typeof i === 'string' ? { title: i } : i))
.map((i) => ({ title: String(i?.title || '').trim(), note: String(i?.note || '') }))
.filter((i) => i.title)
if (!seed.length && templateId) {
const tpl = await prisma.checkListTemplate.findFirst({
where: { id: Number(templateId), OR: [{ builtin: true }, { userId }] },
})
if (tpl && Array.isArray(tpl.items)) {
seed = tpl.items.map((i) => ({ title: String(i?.title || '').trim(), note: String(i?.note || '') })).filter((i) => i.title)
}
}
const row = await prisma.checkList.create({
data: {
userId,
title: cleanTitle,
scene: String(scene),
note: String(note),
items: { create: seed.map((i, idx) => ({ title: i.title, note: i.note, sortOrder: idx })) },
},
include: LIST_INCLUDE,
})
res.json({ item: withProgress(row) })
} catch (e) { next(e) }
})
// GET /api/checklists/:id
router.get('/:id', async (req, res, next) => {
try {
const userId = req.user.sub
const id = Number(req.params.id)
const row = await prisma.checkList.findFirst({ where: { id, userId }, include: LIST_INCLUDE })
if (!row) return res.status(404).json({ error: '清单不存在' })
res.json({ item: withProgress(row) })
} catch (e) { next(e) }
})
// PUT /api/checklists/:id { title?, scene?, note?, archived? }
router.put('/:id', async (req, res, next) => {
try {
const userId = req.user.sub
const id = Number(req.params.id)
const exist = await prisma.checkList.findFirst({ where: { id, userId } })
if (!exist) return res.status(404).json({ error: '清单不存在' })
const { title, scene, note, archived } = req.body || {}
const data = {}
if (title !== undefined) {
const t = String(title).trim()
if (!t) return res.status(400).json({ error: '清单名称不能为空' })
data.title = t
}
if (scene !== undefined) data.scene = String(scene)
if (note !== undefined) data.note = String(note)
if (archived !== undefined) data.archived = !!archived
const row = await prisma.checkList.update({ where: { id }, data, include: LIST_INCLUDE })
res.json({ item: withProgress(row) })
} catch (e) { next(e) }
})
// DELETE /api/checklists/:id
router.delete('/:id', async (req, res, next) => {
try {
const userId = req.user.sub
const id = Number(req.params.id)
const exist = await prisma.checkList.findFirst({ where: { id, userId } })
if (!exist) return res.status(404).json({ error: '清单不存在' })
await prisma.checkListItem.deleteMany({ where: { listId: id } })
await prisma.checkList.delete({ where: { id } })
res.json({ ok: true })
} catch (e) { next(e) }
})
// POST /api/checklists/:id/duplicate 复制一份新清单 (勾选状态重置)
router.post('/:id/duplicate', async (req, res, next) => {
try {
const userId = req.user.sub
const id = Number(req.params.id)
const src = await prisma.checkList.findFirst({ where: { id, userId }, include: LIST_INCLUDE })
if (!src) return res.status(404).json({ error: '清单不存在' })
const row = await prisma.checkList.create({
data: {
userId,
title: src.title + ' 副本',
scene: src.scene,
note: src.note,
items: {
create: src.items.map((i, idx) => ({ title: i.title, note: i.note, done: false, sortOrder: idx })),
},
},
include: LIST_INCLUDE,
})
res.json({ item: withProgress(row) })
} catch (e) { next(e) }
})
// ---------- 检查项 ----------
// POST /api/checklists/:id/items { title, note? }
router.post('/:id/items', async (req, res, next) => {
try {
const userId = req.user.sub
const listId = Number(req.params.id)
const list = await prisma.checkList.findFirst({ where: { id: listId, userId } })
if (!list) return res.status(404).json({ error: '清单不存在' })
const title = String((req.body || {}).title || '').trim()
if (!title) return res.status(400).json({ error: '检查项不能为空' })
if (title.length > 40) return res.status(400).json({ error: '检查项不能超过 40 个字' })
const max = await prisma.checkListItem.findFirst({ where: { listId }, orderBy: { sortOrder: 'desc' } })
const row = await prisma.checkListItem.create({
data: { listId, title, note: String((req.body || {}).note || ''), sortOrder: (max?.sortOrder || 0) + 1 },
})
res.json({ item: row })
} catch (e) { next(e) }
})
// PUT /api/checklists/items/:id { title?, note?, done?, sortOrder? }
router.put('/items/:id', async (req, res, next) => {
try {
const userId = req.user.sub
const id = Number(req.params.id)
const exist = await prisma.checkListItem.findFirst({ where: { id, list: { userId } } })
if (!exist) return res.status(404).json({ error: '检查项不存在' })
const { title, note, done, sortOrder } = req.body || {}
const data = {}
if (title !== undefined) {
const t = String(title).trim()
if (!t) return res.status(400).json({ error: '检查项不能为空' })
data.title = t
}
if (note !== undefined) data.note = String(note)
if (sortOrder !== undefined) data.sortOrder = Number(sortOrder) || 0
if (done !== undefined) {
data.done = !!done
data.doneAt = done ? new Date() : null
}
const row = await prisma.checkListItem.update({ where: { id }, data })
res.json({ item: row })
} catch (e) { next(e) }
})
// DELETE /api/checklists/items/:id
router.delete('/items/:id', async (req, res, next) => {
try {
const userId = req.user.sub
const id = Number(req.params.id)
const exist = await prisma.checkListItem.findFirst({ where: { id, list: { userId } } })
if (!exist) return res.status(404).json({ error: '检查项不存在' })
await prisma.checkListItem.delete({ where: { id } })
res.json({ ok: true })
} catch (e) { next(e) }
})
// POST /api/checklists/:id/check-all { done: boolean } 一键全选 / 全不选
router.post('/:id/check-all', async (req, res, next) => {
try {
const userId = req.user.sub
const listId = Number(req.params.id)
const list = await prisma.checkList.findFirst({ where: { id: listId, userId } })
if (!list) return res.status(404).json({ error: '清单不存在' })
const done = !!(req.body || {}).done
await prisma.checkListItem.updateMany({
where: { listId },
data: { done, doneAt: done ? new Date() : null },
})
const row = await prisma.checkList.findFirst({ where: { id: listId }, include: LIST_INCLUDE })
res.json({ item: withProgress(row) })
} catch (e) { next(e) }
})
export default router
+3 -2
View File
@@ -33,8 +33,9 @@ function guard(handler) {
}
// 授权状态 + 家庭/设备 + 概览 (电量/最新样本/缺失天数)
router.get('/status', guard(async (_req, res) => {
res.json(await getStatus())
// force=1: 跳过后端 5 分钟认证缓存强查 (前端打开页面时用, 保证会话失效能立刻发现)
router.get('/status', guard(async (req, res) => {
res.json(await getStatus(req.query.force === '1'))
}))
// 历史: range = today(默认) | month | year
+337
View File
@@ -0,0 +1,337 @@
// 任务模块: 项目 / 成员 / 看板列
import { Router } from 'express'
import { prisma } from '../db.js'
import { authRequired } from '../middleware.js'
import { loadProjectForUser, loadColumns, loadMemberIds } from '../task-domain/access.js'
const router = Router()
router.use(authRequired)
// 新建项目时的默认 4 列 (T-COL-01, T-BD-09)
export const DEFAULT_COLUMNS = [
{ name: 'Plan', sortOrder: 0, boundStatus: 'todo' },
{ name: 'Doing', sortOrder: 1, boundStatus: 'doing' },
{ name: 'Action', sortOrder: 2, boundStatus: null },
{ name: 'Done', sortOrder: 3, boundStatus: 'done' },
]
export const MAX_COLUMNS = 10
const MAX_NAME = 20
/** 建 4 个默认列 */
async function createDefaultColumns(projectId) {
await prisma.boardColumn.createMany({
data: DEFAULT_COLUMNS.map((c) => ({ ...c, projectId, retention: 'forever' })),
})
}
/** 确保用户有一个个人默认项目 (T-PJ-01), 幂等 */
export async function ensurePersonalProject(userId) {
const exist = await prisma.project.findFirst({ where: { ownerId: userId, isPersonal: true } })
if (exist) return exist
const user = await prisma.user.findUnique({ where: { id: userId }, select: { name: true } })
const name = `${user?.name || '我'}的任务`
const project = await prisma.project.create({
data: { name, ownerId: userId, isPersonal: true, members: { create: { userId } } },
})
await createDefaultColumns(project.id)
return project
}
function projectSummary(p) {
return {
id: p.id,
name: p.name,
ownerId: p.ownerId,
isPersonal: p.isPersonal,
archived: p.archived,
createdAt: p.createdAt,
updatedAt: p.updatedAt,
memberCount: p._count?.members ?? undefined,
taskCount: p._count?.tasks ?? undefined,
}
}
// ---------- 项目 ----------
// GET /api/projects 我参与的项目
router.get('/', async (req, res, next) => {
try {
const userId = req.user.sub
await ensurePersonalProject(userId)
const rows = await prisma.project.findMany({
where: { members: { some: { userId } } },
include: { _count: { select: { members: true, tasks: true } } },
orderBy: [{ archived: 'asc' }, { updatedAt: 'desc' }],
})
res.json({ items: rows.map(projectSummary) })
} catch (e) { next(e) }
})
// POST /api/projects { name }
router.post('/', async (req, res, next) => {
try {
const userId = req.user.sub
const name = String(req.body?.name || '').trim()
if (!name) return res.status(400).json({ error: '项目名称不能为空' })
if (name.length > MAX_NAME) return res.status(400).json({ error: `项目名称不能超过 ${MAX_NAME} 字` })
const project = await prisma.project.create({
data: { name, ownerId: userId, isPersonal: false, members: { create: { userId } } },
})
await createDefaultColumns(project.id)
const full = await prisma.project.findUnique({
where: { id: project.id },
include: { _count: { select: { members: true, tasks: true } } },
})
res.json({ item: projectSummary(full) })
} catch (e) { next(e) }
})
// GET /api/projects/:id 详情(项目 + 成员 + 列)
router.get('/:id', async (req, res, next) => {
try {
const userId = req.user.sub
const project = await loadProjectForUser(req.params.id, userId)
if (!project) return res.status(404).json({ error: '项目不存在或无权限' })
const [members, columns] = await Promise.all([
prisma.projectMember.findMany({
where: { projectId: project.id },
include: { user: { select: { id: true, name: true, email: true } } },
orderBy: { createdAt: 'asc' },
}),
loadColumns(project.id),
])
res.json({
item: {
...projectSummary(project),
members: members.map((m) => ({ userId: m.userId, name: m.user.name, email: m.user.email, permission: m.permission })),
columns,
notes: Array.isArray(project.notes) ? project.notes : [],
},
})
} catch (e) { next(e) }
})
// PUT /api/projects/:id { name?, archived?, notes? }
router.put('/:id', async (req, res, next) => {
try {
const userId = req.user.sub
const project = await loadProjectForUser(req.params.id, userId)
if (!project) return res.status(404).json({ error: '项目不存在或无权限' })
const { name, archived, notes } = req.body || {}
const data = {}
if (name !== undefined) {
const t = String(name).trim()
if (!t) return res.status(400).json({ error: '项目名称不能为空' })
if (t.length > MAX_NAME) return res.status(400).json({ error: `项目名称不能超过 ${MAX_NAME} 字` })
data.name = t
}
if (archived !== undefined) data.archived = !!archived
// 项目信息块 (T-PJ-13): 任意多个 { title, content }, 整体替换
if (notes !== undefined) {
if (!Array.isArray(notes)) return res.status(400).json({ error: '信息块格式非法' })
data.notes = notes.slice(0, 50).map((n) => ({
title: String(n?.title ?? '').slice(0, 50),
content: String(n?.content ?? '').slice(0, 20000),
}))
}
const row = await prisma.project.update({ where: { id: project.id }, data })
res.json({ item: projectSummary(row) })
} catch (e) { next(e) }
})
// DELETE /api/projects/:id 个人项目拒绝 (T-PJ-02, T-EX-09)
router.delete('/:id', async (req, res, next) => {
try {
const userId = req.user.sub
const project = await loadProjectForUser(req.params.id, userId)
if (!project) return res.status(404).json({ error: '项目不存在或无权限' })
if (project.isPersonal) return res.status(400).json({ error: '个人默认项目不可删除' })
await prisma.project.delete({ where: { id: project.id } })
res.json({ ok: true })
} catch (e) { next(e) }
})
// ---------- 成员 ----------
// POST /api/projects/:id/members { userId } | { email }
router.post('/:id/members', async (req, res, next) => {
try {
const userId = req.user.sub
const project = await loadProjectForUser(req.params.id, userId)
if (!project) return res.status(404).json({ error: '项目不存在或无权限' })
if (project.isPersonal) return res.status(400).json({ error: '个人默认项目不可添加成员' })
const { userId: targetId, email } = req.body || {}
let target = null
if (targetId) target = await prisma.user.findUnique({ where: { id: Number(targetId) } })
else if (email) target = await prisma.user.findFirst({ where: { email: String(email).trim() } })
if (!target) return res.status(404).json({ error: '用户不存在' })
const exist = await prisma.projectMember.findUnique({
where: { projectId_userId: { projectId: project.id, userId: target.id } },
})
if (exist) return res.status(409).json({ error: '该用户已是项目成员' })
await prisma.projectMember.create({ data: { projectId: project.id, userId: target.id } })
res.json({ ok: true, member: { userId: target.id, name: target.name } })
} catch (e) { next(e) }
})
// DELETE /api/projects/:id/members/:uid 移除成员 / 自己退出 (T-PJ-06/07)
router.delete('/:id/members/:uid', async (req, res, next) => {
try {
const userId = req.user.sub
const project = await loadProjectForUser(req.params.id, userId)
if (!project) return res.status(404).json({ error: '项目不存在或无权限' })
if (project.isPersonal) return res.status(400).json({ error: '个人默认项目不可移除成员' })
const target = Number(req.params.uid)
// 只能移除自己, 或是创建人移除他人 (本期权限均等, 允许成员自退)
if (target !== userId && project.ownerId !== userId) {
return res.status(403).json({ error: '只能移除自己,或由创建人移除他人' })
}
await prisma.projectMember.deleteMany({ where: { projectId: project.id, userId: target } })
// 成员清空后项目静默保留 (T-PJ-08)
res.json({ ok: true })
} catch (e) { next(e) }
})
// ---------- 看板列 ----------
// GET /api/projects/:id/columns
router.get('/:id/columns', async (req, res, next) => {
try {
const userId = req.user.sub
const project = await loadProjectForUser(req.params.id, userId)
if (!project) return res.status(404).json({ error: '项目不存在或无权限' })
res.json({ items: await loadColumns(project.id) })
} catch (e) { next(e) }
})
// POST /api/projects/:id/columns { name }
router.post('/:id/columns', async (req, res, next) => {
try {
const userId = req.user.sub
const project = await loadProjectForUser(req.params.id, userId)
if (!project) return res.status(404).json({ error: '项目不存在或无权限' })
const name = String(req.body?.name || '').trim()
if (!name) return res.status(400).json({ error: '列名不能为空' })
if (name.length > MAX_NAME) return res.status(400).json({ error: `列名不能超过 ${MAX_NAME} 字` })
const cols = await loadColumns(project.id)
if (cols.length >= MAX_COLUMNS) return res.status(400).json({ error: `最多 ${MAX_COLUMNS} 列` })
if (cols.some((c) => c.name === name)) return res.status(409).json({ error: '列名已存在' })
const row = await prisma.boardColumn.create({
data: {
projectId: project.id,
name,
sortOrder: cols.length ? Math.max(...cols.map((c) => c.sortOrder)) + 1 : 0,
boundStatus: null,
retention: 'forever',
},
})
res.json({ item: row })
} catch (e) { next(e) }
})
// PUT /api/projects/:id/columns/order { ids: number[] }
router.put('/:id/columns/order', async (req, res, next) => {
try {
const userId = req.user.sub
const project = await loadProjectForUser(req.params.id, userId)
if (!project) return res.status(404).json({ error: '项目不存在或无权限' })
const ids = Array.isArray(req.body?.ids) ? req.body.ids.map(Number) : []
const cols = await loadColumns(project.id)
if (ids.length !== cols.length) return res.status(400).json({ error: '列数量不匹配' })
await prisma.$transaction(
ids.map((id, idx) => prisma.boardColumn.update({ where: { id }, data: { sortOrder: idx } })),
)
res.json({ items: await loadColumns(project.id) })
} catch (e) { next(e) }
})
// PUT /api/columns/:id { name?, boundStatus?, retention? }
export const columnRouter = Router()
columnRouter.use(authRequired)
columnRouter.put('/:id', async (req, res, next) => {
try {
const userId = req.user.sub
const id = Number(req.params.id)
const col = await prisma.boardColumn.findUnique({ where: { id } })
if (!col) return res.status(404).json({ error: '列不存在' })
const project = await loadProjectForUser(col.projectId, userId)
if (!project) return res.status(404).json({ error: '项目不存在或无权限' })
const { name, boundStatus, retention } = req.body || {}
const data = {}
if (name !== undefined) {
const t = String(name).trim()
if (!t) return res.status(400).json({ error: '列名不能为空' })
if (t.length > MAX_NAME) return res.status(400).json({ error: `列名不能超过 ${MAX_NAME} 字` })
const dup = await prisma.boardColumn.findFirst({ where: { projectId: col.projectId, name: t, NOT: { id } } })
if (dup) return res.status(409).json({ error: '列名已存在' })
data.name = t
}
if (boundStatus !== undefined) {
// null | todo | doing | done
if (boundStatus !== null && !['todo', 'doing', 'done'].includes(boundStatus)) {
return res.status(400).json({ error: '绑定状态取值非法' })
}
data.boundStatus = boundStatus
}
if (retention !== undefined) {
if (!['never', 'today', '3d', 'week', 'month', 'forever'].includes(retention)) {
return res.status(400).json({ error: '保留选项取值非法' })
}
data.retention = retention
}
const row = await prisma.boardColumn.update({ where: { id }, data })
res.json({ item: row })
} catch (e) { next(e) }
})
// DELETE /api/columns/:id?moveToColumnId=N 删列并迁移任务 (T-COL-04, T-EX-16/17)
columnRouter.delete('/:id', async (req, res, next) => {
try {
const userId = req.user.sub
const id = Number(req.params.id)
const col = await prisma.boardColumn.findUnique({ where: { id } })
if (!col) return res.status(404).json({ error: '列不存在' })
const project = await loadProjectForUser(col.projectId, userId)
if (!project) return res.status(404).json({ error: '项目不存在或无权限' })
const cols = await loadColumns(col.projectId)
if (cols.length <= 1) return res.status(400).json({ error: '至少保留一列' })
// 迁移目标: 指定列 否则相邻列(优先后一个, 没有则前一个)
let targetId = Number(req.query.moveToColumnId) || 0
if (!targetId) {
const idx = cols.findIndex((c) => c.id === id)
targetId = (cols[idx + 1] || cols[idx - 1]).id
}
const target = cols.find((c) => c.id === targetId)
if (!target || target.id === id) return res.status(400).json({ error: '迁移目标列非法' })
// 迁移任务: 状态按目标列绑定重新裁决 (设计文档 §2.4)
const tasks = await prisma.task.findMany({ where: { columnId: id } })
const newStatus = target.boundStatus || null
await prisma.task.updateMany({
where: { columnId: id },
data: {
columnId: target.id,
...(newStatus ? { status: newStatus } : {}),
},
})
await prisma.boardColumn.delete({ where: { id } })
res.json({ ok: true, moved: tasks.length, toColumnId: target.id })
} catch (e) { next(e) }
})
export { createDefaultColumns, loadMemberIds }
export default router
+284
View File
@@ -0,0 +1,284 @@
// 任务模块: 任务 / 子任务 / 跨项目拷贝
import { Router } from 'express'
import { prisma } from '../db.js'
import { authRequired } from '../middleware.js'
import {
loadTaskForUser, loadProjectForUser, loadColumns, loadMemberIds,
} from '../task-domain/access.js'
import { resolveTaskWrite } from '../task-domain/resolveStatus.js'
import { filterVisible } from '../task-domain/retention.js'
import { copyTask } from '../task-domain/copyTask.js'
const router = Router()
router.use(authRequired)
const TASK_INCLUDE = {
column: true,
assignee: { select: { id: true, name: true } },
creator: { select: { id: true, name: true } },
members: { include: { user: { select: { id: true, name: true } } } },
tags: true,
children: {
include: {
column: true,
assignee: { select: { id: true, name: true } },
members: { include: { user: { select: { id: true, name: true } } } },
tags: true,
},
orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }],
},
}
// 归一化: members / tags 平铺为数组, 便于前端直接用
function shape(task) {
if (!task) return task
const out = {
...task,
members: (task.members || []).map((m) => ({ userId: m.userId, name: m.user?.name })),
tags: (task.tags || []).map((t) => t.name),
}
if (Array.isArray(task.children)) out.children = task.children.map(shape)
return out
}
// ---------- 项目下的任务列表 ----------
// GET /api/projects/:id/tasks
router.get('/projects/:id/tasks', async (req, res, next) => {
try {
const userId = req.user.sub
const project = await loadProjectForUser(req.params.id, userId)
if (!project) return res.status(404).json({ error: '项目不存在或无权限' })
const columns = await loadColumns(project.id)
const rows = await prisma.task.findMany({
where: { projectId: project.id, parentId: null },
include: TASK_INCLUDE,
orderBy: [{ sortOrder: 'asc' }, { createdAt: 'asc' }],
})
const shaped = rows.map(shape)
// 保留选项过滤 (T-FL-*): 已完成任务按所属列配置隐藏
const visible = filterVisible(shaped, columns)
res.json({ items: visible, columns, total: shaped.length })
} catch (e) { next(e) }
})
// POST /api/projects/:id/tasks { title, description?, parentId?, assigneeId?, memberIds?, tags? }
router.post('/projects/:id/tasks', async (req, res, next) => {
try {
const userId = req.user.sub
const project = await loadProjectForUser(req.params.id, userId)
if (!project) return res.status(404).json({ error: '项目不存在或无权限' })
const { title, description = '', parentId, assigneeId, memberIds = [], tags = [] } = req.body || {}
const cleanTitle = String(title || '').trim()
if (!cleanTitle) return res.status(400).json({ error: '任务标题不能为空' })
if (cleanTitle.length > 200) return res.status(400).json({ error: '任务标题不能超过 200 字' })
const columns = await loadColumns(project.id)
if (!columns.length) return res.status(400).json({ error: '项目没有可用的看板列' })
// 子任务校验: 父任务必须存在、同项目、且本身是顶层任务 (T-ST-01/02, T-EX-12)
let pid = null
if (parentId) {
const parent = await prisma.task.findUnique({ where: { id: Number(parentId) } })
if (!parent) return res.status(404).json({ error: '父任务不存在' })
if (parent.projectId !== project.id) return res.status(400).json({ error: '子任务必须与父任务同项目' })
if (parent.parentId) return res.status(400).json({ error: '仅支持一级子任务' })
pid = parent.id
}
const memberIds_ = await loadMemberIds(project.id)
const memberSet = new Set(memberIds_)
// 负责人: 未指定 => 默认创建者本人 (T-TK-09); 显式 null => 不指派
let finalAssignee = null
if (assigneeId === undefined) finalAssignee = memberSet.has(userId) ? userId : null
else if (assigneeId !== null) {
const aid = Number(assigneeId)
if (!memberSet.has(aid)) return res.status(400).json({ error: '负责人必须是项目成员' })
finalAssignee = aid
}
const firstCol = columns[0]
const row = await prisma.task.create({
data: {
projectId: project.id,
columnId: firstCol.id,
parentId: pid,
title: cleanTitle,
description: String(description || ''),
status: firstCol.boundStatus || 'todo',
assigneeId: finalAssignee,
creatorId: userId,
extras: [],
members: {
create: (Array.isArray(memberIds) ? memberIds : [])
.map(Number).filter((id) => memberSet.has(id))
.map((uid) => ({ userId: uid })),
},
tags: { create: (Array.isArray(tags) ? tags : []).filter(Boolean).map((n) => ({ name: String(n).slice(0, 20) })) },
},
include: TASK_INCLUDE,
})
res.json({ item: shape(row) })
} catch (e) { next(e) }
})
// ---------- 单条任务 ----------
// GET /api/tasks/:id
router.get('/tasks/:id', async (req, res, next) => {
try {
const userId = req.user.sub
const found = await loadTaskForUser(req.params.id, userId)
if (!found) return res.status(404).json({ error: '任务不存在或无权限' })
res.json({ item: shape(found.task) })
} catch (e) { next(e) }
})
// PUT /api/tasks/:id 关键: 列/状态联动裁决
router.put('/tasks/:id', async (req, res, next) => {
try {
const userId = req.user.sub
const found = await loadTaskForUser(req.params.id, userId)
if (!found) return res.status(404).json({ error: '任务不存在或无权限' })
const { task, project } = found
const body = req.body || {}
const columns = await loadColumns(project.id)
const data = {}
// 文本 / 扩展信息
if (body.title !== undefined) {
const t = String(body.title).trim()
if (!t) return res.status(400).json({ error: '任务标题不能为空' })
if (t.length > 200) return res.status(400).json({ error: '任务标题不能超过 200 字' })
data.title = t
}
if (body.description !== undefined) data.description = String(body.description)
if (body.extras !== undefined) {
if (!Array.isArray(body.extras)) return res.status(400).json({ error: '扩展信息格式非法' })
data.extras = body.extras.map((e) => ({
label: String(e?.label ?? '').slice(0, 40),
value: String(e?.value ?? ''),
url: e?.url ? String(e.url) : '',
}))
}
if (body.sortOrder !== undefined) data.sortOrder = Number(body.sortOrder) || 0
// 负责人: 必须是项目成员或 null (T-AS-03)
const memberIds = await loadMemberIds(project.id)
const memberSet = new Set(memberIds)
if (body.assigneeId !== undefined) {
if (body.assigneeId === null || body.assigneeId === '') data.assigneeId = null
else {
const aid = Number(body.assigneeId)
if (!memberSet.has(aid)) return res.status(400).json({ error: '负责人必须是项目成员' })
data.assigneeId = aid
}
}
// 列 / 状态: 走统一裁决 (T-BD-*)
const patch = {}
if (body.columnId !== undefined) patch.columnId = Number(body.columnId)
if (body.status !== undefined) {
if (!['todo', 'doing', 'done'].includes(body.status)) return res.status(400).json({ error: '任务状态取值非法' })
patch.status = body.status
}
if (patch.columnId !== undefined && !columns.some((c) => c.id === patch.columnId)) {
return res.status(400).json({ error: '看板列不存在' })
}
let finalStatus = task.status
if (Object.keys(patch).length) {
const r = resolveTaskWrite(task, patch, columns)
data.status = r.status
data.columnId = r.columnId
finalStatus = r.status
}
// 完成时间: 进入 done 写 now, 退出 done 置空 (T-FL-10)
if (finalStatus === 'done' && task.status !== 'done') data.doneAt = new Date()
else if (finalStatus !== 'done' && data.status !== undefined) data.doneAt = null
// 成员 / 标签: 整体替换
if (Array.isArray(body.memberIds)) {
const ids = body.memberIds.map(Number).filter((id) => memberSet.has(id))
await prisma.taskMember.deleteMany({ where: { taskId: task.id } })
if (ids.length) {
await prisma.taskMember.createMany({
data: ids.map((uid) => ({ taskId: task.id, userId: uid })),
})
}
}
if (Array.isArray(body.tags)) {
await prisma.taskTag.deleteMany({ where: { taskId: task.id } })
const names = body.tags.map((n) => String(n).trim()).filter(Boolean).slice(0, 10)
if (names.length) {
await prisma.taskTag.createMany({ data: names.map((name) => ({ taskId: task.id, name: name.slice(0, 20) })) })
}
}
// 拖拽降为子任务 (T-ST-12~15)
if (body.parentId !== undefined) {
if (body.parentId === null) {
data.parentId = null
} else {
const pid = Number(body.parentId)
if (pid === task.id) return res.status(400).json({ error: '不能成为自己的子任务' })
const parent = await prisma.task.findUnique({ where: { id: pid } })
if (!parent) return res.status(404).json({ error: '目标任务不存在' })
if (parent.projectId !== task.projectId) return res.status(400).json({ error: '子任务必须与父任务同项目' })
if (parent.parentId) return res.status(400).json({ error: '仅支持一级子任务' })
const childCount = await prisma.task.count({ where: { parentId: task.id } })
if (childCount > 0) return res.status(400).json({ error: '该任务已有子任务,不能成为子任务' })
data.parentId = pid
}
}
const row = await prisma.task.update({ where: { id: task.id }, data, include: TASK_INCLUDE })
res.json({ item: shape(row) })
} catch (e) { next(e) }
})
// DELETE /api/tasks/:id 级联删除子任务 (T-ST-11, T-EX-07)
router.delete('/tasks/:id', async (req, res, next) => {
try {
const userId = req.user.sub
const found = await loadTaskForUser(req.params.id, userId)
if (!found) return res.status(404).json({ error: '任务不存在或无权限' })
const childCount = await prisma.task.count({ where: { parentId: found.task.id } })
await prisma.task.delete({ where: { id: found.task.id } })
res.json({ ok: true, deletedSubtasks: childCount })
} catch (e) { next(e) }
})
// POST /api/tasks/:id/copy { projectId } 跨项目拷贝 (T-CP-*)
router.post('/tasks/:id/copy', async (req, res, next) => {
try {
const userId = req.user.sub
const found = await loadTaskForUser(req.params.id, userId)
if (!found) return res.status(404).json({ error: '任务不存在或无权限' })
const targetId = Number(req.body?.projectId)
const targetProject = await loadProjectForUser(targetId, userId)
if (!targetProject) return res.status(403).json({ error: '目标项目不存在或你不是其成员' })
const targetColumns = await loadColumns(targetProject.id)
if (!targetColumns.length) return res.status(400).json({ error: '目标项目没有可用的看板列' })
const targetMemberIds = await loadMemberIds(targetProject.id)
// 源任务需带列名映射信息
const sourceColumns = await loadColumns(found.project.id)
const source = { ...found.task, __columns: sourceColumns }
const result = await copyTask({
source, targetProject, targetColumns, operatorId: userId, targetMemberIds,
})
const fresh = await prisma.task.findUnique({ where: { id: result.task.id }, include: TASK_INCLUDE })
res.json({ item: shape(fresh), dropped: result.dropped })
} catch (e) { next(e) }
})
export default router
+58
View File
@@ -0,0 +1,58 @@
// 任务模块: 项目成员校验 (T-PM-01, B-13)
// 所有 /api/projects/:id/* 与任务写操作都必须确认请求者是项目成员.
import { prisma } from '../db.js'
/** 取出当前用户可访问的项目 (不存在或非成员 -> null) */
export async function loadProjectForUser(projectId, userId) {
const id = Number(projectId)
if (!Number.isInteger(id) || id <= 0) return null
const project = await prisma.project.findFirst({
where: { id, members: { some: { userId } } },
})
return project || null
}
/** 任务 -> 校验其项目可访问, 返回 { task, project } */
export async function loadTaskForUser(taskId, userId) {
const id = Number(taskId)
if (!Number.isInteger(id) || id <= 0) return null
const task = await prisma.task.findFirst({
where: { id, project: { members: { some: { userId } } } },
include: {
column: true,
assignee: { select: { id: true, name: true } },
creator: { select: { id: true, name: true } },
members: { include: { user: { select: { id: true, name: true } } } },
tags: true,
children: {
include: {
column: true,
assignee: { select: { id: true, name: true } },
members: { include: { user: { select: { id: true, name: true } } } },
tags: true,
},
orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }],
},
},
})
if (!task) return null
const project = await prisma.project.findUnique({ where: { id: task.projectId } })
return { task, project }
}
/** 项目全部列 (按显示顺序) */
export async function loadColumns(projectId) {
return prisma.boardColumn.findMany({
where: { projectId: Number(projectId) },
orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }],
})
}
/** 项目成员 userId 集合 */
export async function loadMemberIds(projectId) {
const rows = await prisma.projectMember.findMany({
where: { projectId: Number(projectId) },
select: { userId: true },
})
return rows.map((r) => r.userId)
}
+89
View File
@@ -0,0 +1,89 @@
// 任务模块: 跨项目拷贝 (T-CP-*)
// 拷贝而非移动: 新建记录 + 深拷贝子任务, 源任务零改动.
import { prisma } from '../db.js'
/**
* 拷贝一条任务(含子任务)到目标项目。
*
* 规则:
* - 全字段复制: 标题/描述/状态/列/扩展卡片/标签 (T-CP-02)
* - 子任务一并复制, 且归属目标项目 (T-CP-03, T-CP-08)
* - creatorId = 操作者 (T-CP-04)
* - 负责人 / 任务成员只保留"属于目标项目成员"的部分, 其余丢弃并回报 (T-CP-07, T-EX-08)
* - 状态与列沿用源值, 不强行纠正 (T-EX-14)
*
* @returns {{ task, dropped: { assignee: null|{id,name}, members: {id,name}[] } }}
*/
export async function copyTask({ source, targetProject, targetColumns, operatorId, targetMemberIds }) {
const memberSet = new Set(targetMemberIds)
// 列映射: 目标项目里找同名列, 找不到就用第一列 (保证 columnId 合法)
const pickColumn = (sourceColumnId) => {
const srcCol = source.__columns?.find((c) => c.id === sourceColumnId)
if (srcCol) {
const same = targetColumns.find((c) => c.name === srcCol.name)
if (same) return same.id
}
return targetColumns[0]?.id
}
// 人员过滤
const keepAssignee = source.assigneeId && memberSet.has(source.assigneeId) ? source.assigneeId : null
const droppedMembers = []
const keptMemberIds = []
for (const m of source.members || []) {
if (memberSet.has(m.userId)) keptMemberIds.push(m.userId)
else droppedMembers.push(m.user)
}
const droppedAssignee = source.assigneeId && !memberSet.has(source.assigneeId) ? source.assignee : null
const baseData = (extra) => ({
projectId: targetProject.id,
title: source.title,
description: source.description || '',
status: source.status,
columnId: extra.columnId,
parentId: extra.parentId ?? null,
assigneeId: extra.parentId ? (source.assigneeId && memberSet.has(source.assigneeId) ? source.assigneeId : null) : keepAssignee,
creatorId: operatorId,
extras: source.extras ?? [],
doneAt: source.status === 'done' ? (source.doneAt || new Date()) : null,
sortOrder: source.sortOrder ?? 0,
tags: {
create: (source.tags || []).map((t) => ({ name: t.name })),
},
})
const newTask = await prisma.task.create({
data: {
...baseData({ columnId: pickColumn(source.columnId) }),
members: {
create: keptMemberIds.map((uid) => ({ userId: uid })),
},
},
})
// 子任务: 同法复制, parentId 指向新父任务
for (const child of source.children || []) {
const childMembers = (child.members || [])
.filter((m) => memberSet.has(m.userId))
.map((m) => m.userId)
await prisma.task.create({
data: {
...baseData({
columnId: pickColumn(child.columnId),
parentId: newTask.id,
}),
members: { create: childMembers.map((uid) => ({ userId: uid })) },
},
})
}
return {
task: newTask,
dropped: {
assignee: droppedAssignee ? { id: droppedAssignee.id, name: droppedAssignee.name } : null,
members: droppedMembers.map((u) => ({ id: u.id, name: u.name })),
},
}
}
+93
View File
@@ -0,0 +1,93 @@
// 任务模块: 自然日边界工具 (Asia/Shanghai)
// 与前端 src/pages/task/date.js 保持同一口径, 改动需同步
//
// 保留选项口径 (T-FL-02):
// never 永不显示
// today 当天 -> 今天 00:00
// 3d 3 天 -> 今天-2 天 00:00 (含今天共 3 个自然日)
// week 本周 -> 本周一 00:00
// month 本月 -> 本月 1 日 00:00
// forever 永久显示 -> 无下界
export const TZ = 'Asia/Shanghai'
export const RETENTION = ['never', 'today', '3d', 'week', 'month', 'forever']
export const RETENTION_LABEL = {
never: '永不显示',
today: '当天',
'3d': '3 天',
week: '本周',
month: '本月',
forever: '永久显示',
}
export const STATUSES = ['todo', 'doing', 'done']
export const STATUS_LABEL = { todo: '未开始', doing: '执行中', done: '完成' }
// 当前时刻在 Asia/Shanghai 的 "YYYY-MM-DD" 与星期几
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 startOfWeek(s) {
const dow = (toUTC(s).getUTCDay() + 6) % 7
return addDays(s, -dow)
}
export function startOfMonth(s) {
return String(s).slice(0, 8) + '01'
}
/**
* 保留选项 -> 起始时间(不含时区偏移的 Date, 以 UTC 表示当日零点)
* 返回 null 表示"全可见"(forever); 返回 'never' 表示"全隐藏"
*/
export function retentionStart(option, now = new Date()) {
const today = toDateStr(now)
switch (option) {
case 'never': return { mode: 'never' }
case 'forever': return { mode: 'all' }
case 'today': return { mode: 'since', date: today }
case '3d': return { mode: 'since', date: addDays(today, -2) }
case 'week': return { mode: 'since', date: startOfWeek(today) }
case 'month': return { mode: 'since', date: startOfMonth(today) }
default: return { mode: 'all' }
}
}
/**
* 判断一条已完成任务在给定保留选项下是否可见。
* 依据任务的完成时刻 doneAt (T-FL-10); 未完成任务恒可见 (T-FL-12)。
*/
export function isVisibleByRetention(task, option, now = new Date()) {
if (task.status !== 'done') return true
const win = retentionStart(option, now)
if (win.mode === 'never') return false
if (win.mode === 'all') return true
if (!task.doneAt) return false
// doneAt 是绝对时刻, 转成本地日历日再与窗口起点比较
const doneDay = toDateStr(task.doneAt)
return doneDay >= win.date
}
+85
View File
@@ -0,0 +1,85 @@
// 任务模块: 看板列 与 任务状态 的联动裁决 (唯一口径)
// 对应需求: T-BD-04/05/06/07/08/13/14/15/16
//
// 两个正交维度:
// Task.columnId 空间 (哪一列)
// Task.status 属性 (todo/doing/done)
// 列上 boundStatus 决定二者是否联动:
// null 该列与状态完全独立
// todo等 该列绑定到该状态
//
// 三种场景:
// A 拖拽换列 -> 只看目标列: 目标列绑定则状态跟随, 不绑定则保留原状态
// B 改状态 -> 当前列已匹配则不改列; 否则按列序找第一个绑定该状态的列 (找不到就不动)
// C 子任务 -> 跳过全部联动
/** 列按 sortOrder 升序 (调用方保证已排序, 这里再兜底一次) */
export function sortColumns(columns) {
return [...columns].sort((a, b) => (a.sortOrder - b.sortOrder) || (a.id - b.id))
}
/**
* 场景 A: 任务从任意列拖到 targetColumn
* @returns {{ status: string }} 新状态
*/
export function statusOnColumnChange(currentStatus, targetColumn) {
if (!targetColumn) return { status: currentStatus }
if (targetColumn.boundStatus) return { status: targetColumn.boundStatus }
// 目标列不绑定 => 保留原状态 (T-BD-07)
return { status: currentStatus }
}
/**
* 场景 B: 任务状态改为 newStatus 后, 应该落在哪一列
* @returns {{ columnId: number, moved: boolean }}
*/
export function columnOnStatusChange(task, newStatus, columns) {
// 已匹配 (当前列就绑定该状态) => 原地不动
if (task.column && task.column.boundStatus === newStatus) {
return { columnId: task.columnId, moved: false }
}
// 按当前列顺序取第一个绑定该状态的列 (T-BD-06, T-BD-15)
const ordered = sortColumns(columns || [])
const target = ordered.find((c) => c.boundStatus === newStatus)
if (target) return { columnId: target.id, moved: target.id !== task.columnId }
// 无匹配列 => 不移动, 保留原列 (T-BD-16)
return { columnId: task.columnId, moved: false }
}
/** 子任务豁免: 不参与任何列/状态联动 (T-BD-13) */
export function isSubtask(task) {
return task.parentId != null
}
/**
* 统一裁决入口: 写接口调用它算出最终的 status 与 columnId
*
* @param {object} task 现有任务 (含 columnId, status, parentId, column)
* @param {object} patch 本次提交 { columnId?, status? }
* @param {object[]} columns 项目全部列
* @returns {{ status: string, columnId: number }}
*/
export function resolveTaskWrite(task, patch, columns) {
let status = patch.status !== undefined ? patch.status : task.status
let columnId = patch.columnId !== undefined ? patch.columnId : task.columnId
// 子任务: 原样落库
if (isSubtask(task)) return { status, columnId }
const ordered = sortColumns(columns || [])
const targetColumn = ordered.find((c) => c.id === columnId)
// 场景 A: 显式换列
if (patch.columnId !== undefined && targetColumn) {
status = statusOnColumnChange(status, targetColumn).status
}
// 场景 B: 状态变了(或换列后状态变了) => 检查是否需要归位
// 注意: 只在"状态发生变化"或"显式改状态"时才归位, 单纯拖拽已由 A 处理
if (patch.status !== undefined) {
const next = columnOnStatusChange({ ...task, columnId, column: targetColumn }, status, ordered)
columnId = next.columnId
}
return { status, columnId }
}
+31
View File
@@ -0,0 +1,31 @@
// 任务模块: 保留选项的可见性过滤 (T-FL-*)
// 前端 src/pages/task/date.js 有一份同口径实现, 用于即时过滤; 后端用于统计/兜底
import { isVisibleByRetention } from './date.js'
/**
* 按"任务所属列"的保留选项过滤任务列表 (T-FL-11)
* @param {object[]} tasks 任务(需含 status / doneAt / columnId)
* @param {object[]} columns 列(需含 id / retention)
*/
export function filterVisible(tasks, columns, now = new Date()) {
const retentionById = new Map((columns || []).map((c) => [c.id, c.retention || 'forever']))
return (tasks || []).filter((t) => {
const opt = retentionById.get(t.columnId) || 'forever'
return isVisibleByRetention(t, opt, now)
})
}
/** 单条判定, 供详情接口复用 */
export function isTaskVisible(task, columns, now = new Date()) {
const col = (columns || []).find((c) => c.id === task.columnId)
return isVisibleByRetention(task, col?.retention || 'forever', now)
}
/** 列内计数: 过滤后条数 (T-FL-14 计数与展示同源) */
export function countByColumn(tasks, columns, now = new Date()) {
const visible = filterVisible(tasks, columns, now)
const out = {}
for (const c of columns || []) out[c.id] = 0
for (const t of visible) out[t.columnId] = (out[t.columnId] || 0) + 1
return out
}
+1 -1
View File
@@ -1456,7 +1456,7 @@ dependencies = [
[[package]]
name = "iboard"
version = "0.1.0"
version = "0.2.0"
dependencies = [
"serde",
"serde_json",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "iboard"
version = "0.1.0"
version = "0.2.0"
description = "iBoard - 经济数据与策略"
authors = ["iBoard Team"]
edition = "2021"
+1 -1
View File
@@ -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",
+17 -7
View File
@@ -1,19 +1,22 @@
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 TaskPage from './pages/task/TaskPage.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 +34,21 @@ 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): 首屏(最近项目+当前项目) / 项目列表 / 深链 (T-VW-08/09) */}
<Route path="/task" element={<TaskPage />} />
<Route path="/task/list" element={<TaskPage />} />
<Route path="/task/:pid" element={<TaskPage />} />
{/* 宏观: 报警 / 新闻 / 油价 / 经济数据 / 策略研究 / 经济文章 (V0.2 原首页并入, 默认新闻) */}
<Route path="/macro" element={<Navigate to="/macro/news" replace />} />
<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>
)
}
+41
View File
@@ -0,0 +1,41 @@
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'),
template: (id) => api(`/checklists/templates/${id}`),
createTemplate: (body) => api('/checklists/templates', { method: 'POST', body }),
updateTemplate: (id, body) => api(`/checklists/templates/${id}`, { method: 'PUT', body }),
removeTemplate: (id) => api(`/checklists/templates/${id}`, { method: 'DELETE' }),
}
+18 -7
View File
@@ -1,9 +1,10 @@
// 环境监控 API: 走后端 /api/mihome/* (凭证/token 不出前端)
import { api, getApiBase } from './client.js'
import { api, getApiBase, getToken } from './client.js'
// 状态: 授权 + 家庭(设备) + 电量 + 最新样本 + 缺日 + 统计可用性
export async function getMihomeStatus() {
return api('/mihome/status')
// force=true 让后端跳过 5 分钟认证缓存, 立刻回真实授权状态
export async function getMihomeStatus(force) {
return api('/mihome/status' + (force ? '?force=1' : ''))
}
// 历史: range = today | month | year
@@ -32,8 +33,18 @@ export async function cancelMihomeAuth() {
return api('/mihome/reauthorize/cancel', { method: 'POST' })
}
// 二维码图片地址 (服务端带小米 UA 代理, 避免 CDN 拒连)
export function mihomeQrImageUrl() {
const base = getApiBase() // 同源时为空串
return base + '/api/mihome/reauthorize/qr-image'
// 二维码图片: 后端接口在 authRequired 后面, 而 <img src> 无法带 Authorization 头,
// 直接塞地址会 401 (二维码位置一片空白). 这里用 fetch 带 JWT 取回二进制, 再转 blob URL 给 <img>.
export async function fetchMihomeQrImage() {
const token = getToken()
const res = await fetch(getApiBase() + '/api/mihome/reauthorize/qr-image', {
headers: token ? { Authorization: 'Bearer ' + token } : {},
cache: 'no-store',
})
if (!res.ok) {
const data = await res.json().catch(() => ({}))
throw new Error(data.error || '二维码加载失败 (' + res.status + ')')
}
const blob = await res.blob()
return URL.createObjectURL(blob)
}
+99 -17
View File
@@ -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 {
+40
View File
@@ -0,0 +1,40 @@
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 projectApi = {
list: () => api('/projects'),
detail: (id) => api(`/projects/${id}`),
create: (body) => api('/projects', { method: 'POST', body }),
update: (id, body) => api(`/projects/${id}`, { method: 'PUT', body }),
remove: (id) => api(`/projects/${id}`, { method: 'DELETE' }),
addMember: (id, body) => api(`/projects/${id}/members`, { method: 'POST', body }),
removeMember: (id, uid) => api(`/projects/${id}/members/${uid}`, { method: 'DELETE' }),
}
// 看板列
export const columnApi = {
list: (projectId) => api(`/projects/${projectId}/columns`),
add: (projectId, body) => api(`/projects/${projectId}/columns`, { method: 'POST', body }),
update: (id, body) => api(`/columns/${id}`, { method: 'PUT', body }),
remove: (id, moveToColumnId) => api(`/columns/${id}${q({ moveToColumnId })}`, { method: 'DELETE' }),
order: (projectId, ids) => api(`/projects/${projectId}/columns/order`, { method: 'PUT', body: { ids } }),
}
// 任务
export const taskApi = {
list: (projectId) => api(`/projects/${projectId}/tasks`),
create: (projectId, body) => api(`/projects/${projectId}/tasks`, { method: 'POST', body }),
detail: (id) => api(`/tasks/${id}`),
update: (id, body) => api(`/tasks/${id}`, { method: 'PUT', body }),
remove: (id) => api(`/tasks/${id}`, { method: 'DELETE' }),
copy: (id, projectId) => api(`/tasks/${id}/copy`, { method: 'POST', body: { projectId } }),
}
+9 -7
View File
@@ -4,23 +4,25 @@ import TabIcon from './TabIcon.jsx'
// 底部 Tab 配置: key 既是路由前缀, 又是 icon 名
// 使用 antd-mobile TabBar: 自带 safe-area 处理, 仿原生 iOS/Android 风格
// V0.2: 打卡置最左并为默认 Tab; 原「首页」的报警/新闻并入「宏观」, 不再有首页 Tab
// V0.2 任务模块: 「任务」插入到「打卡」之后 (T-PM-06), 默认首页仍为打卡
const TABS = [
{ key: '/', name: 'home', label: '首页' },
{ key: '/checkin', name: 'checkin', label: '打卡' },
{ key: '/task', name: 'task', 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 +48,4 @@ export default function BottomNav() {
))}
</TabBar>
)
}
}
+12
View File
@@ -0,0 +1,12 @@
// 四个主 Tab 的统一头部 (V0.2.1 界面调整)
// - 高度为原 antd-mobile NavBar 的一半 (23px)
// - 标题小字 + 主色, 仅作定位用, 不抢内容视觉
// - 「我」页不使用该头部 (页面顶部直接是头像区), 因此本组件只被
// 打卡 / 宏观 / 监控 三页引用, 互不影响
export default function PageHeader({ title }) {
return (
<header className="page-header">
<span className="page-header-title">{title}</span>
</header>
)
}
+120
View File
@@ -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 */ }
}
+65 -2
View File
@@ -1,20 +1,83 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { Tabs } from 'antd-mobile'
// 顶部分类 + 下方仅渲染当前激活 tab 的内容
// - 顶部: antd-mobile Tabs (点击切换)
// - 下方: 同时挂载所有 tab, 通过 [hidden] 仅显示激活的那个 (保留各 tab 内部状态)
// - 右侧溢出提示: 当 tab 条还能向右滚动时, 右边缘显示渐变 + 箭头,
// 提示"右侧还有更多 tab"; 滚动到末尾后自动消失
// props:
// tabs: [{ key, label, render }]
// active: 当前激活 key
// onChange(key): 切换回调
export default function SwipeTabs({ tabs, active, onChange }) {
const wrapRef = useRef(null)
const [more, setMore] = useState(false)
const [headerH, setHeaderH] = useState(45)
// 判断右侧是否还有未展示的 tab (顺带记录 tab 条高度, 让提示层只覆盖页签行)
const measure = useCallback(() => {
const wrap = wrapRef.current
if (!wrap) return
const list = wrap.querySelector('.adm-tabs-tab-list')
const header = wrap.querySelector('.adm-tabs-header')
if (header && header.offsetHeight > 0) setHeaderH(header.offsetHeight)
if (!list) return
setMore(list.scrollLeft + list.clientWidth < list.scrollWidth - 2)
}, [])
useEffect(() => {
const wrap = wrapRef.current
if (!wrap) return
const list = wrap.querySelector('.adm-tabs-tab-list')
const header = wrap.querySelector('.adm-tabs-header')
if (!list) return
// 首次布局未稳定 (字体/宽度), 下一帧 + 300ms 各补测一次
const raf = requestAnimationFrame(measure)
const timer = setTimeout(measure, 300)
list.addEventListener('scroll', measure, { passive: true })
const ro = new ResizeObserver(measure)
ro.observe(list)
if (header) ro.observe(header)
return () => {
cancelAnimationFrame(raf)
clearTimeout(timer)
list.removeEventListener('scroll', measure)
ro.disconnect()
}
}, [measure, tabs.length])
// 切换 tab 后把激活项滚到可见区域中间, 避免选中项被藏在屏幕外
useEffect(() => {
const wrap = wrapRef.current
if (!wrap) return
const list = wrap.querySelector('.adm-tabs-tab-list')
const activeEl = wrap.querySelector('.adm-tabs-tab-active')
if (!list || !activeEl) return
const target = activeEl.offsetLeft - (list.clientWidth - activeEl.offsetWidth) / 2
list.scrollTo({ left: Math.max(0, target), behavior: 'smooth' })
}, [active, tabs.length])
return (
<div className="swipe-tabs-wrap">
<div className="swipe-tabs-wrap" ref={wrapRef}>
<Tabs activeKey={active} onChange={onChange}>
{tabs.map((t) => (
<Tabs.Tab key={t.key} title={t.label} />
))}
</Tabs>
{more && (
<div
className="tab-more-hint"
style={{ height: headerH }}
onClick={() => {
const list = wrapRef.current?.querySelector('.adm-tabs-tab-list')
if (list) list.scrollTo({ left: list.scrollLeft + list.clientWidth * 0.6, behavior: 'smooth' })
}}
>
<span className="tab-more-arrow">›</span>
</div>
)}
<div className="tab-content">
{tabs.map((t) => (
<div key={t.key} className="tab-pane" hidden={t.key !== active}>
@@ -24,4 +87,4 @@ export default function SwipeTabs({ tabs, active, onChange }) {
</div>
</div>
)
}
}
+21 -8
View File
@@ -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,25 @@ 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>
),
// 任务: 清单本 + 勾选行 (V0.2 任务模块)
task: (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="4" y="4" width="16" height="17" rx="2" fill={active ? 'currentColor' : 'none'} fillOpacity={active ? 0.12 : 0} />
<path d="M8 9.5l1.4 1.4L12 8.3" />
<path d="M14.5 9.5h3" />
<path d="M8 15.5l1.4 1.4L12 14.3" />
<path d="M14.5 15.5h3" />
</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 +47,4 @@ export default function TabIcon({ name, active }) {
const render = ICONS[name]
if (!render) return null
return <span className="tab-icon">{render(!!active)}</span>
}
}
+61
View File
@@ -0,0 +1,61 @@
import { useEffect, useState } from 'react'
import { DotLoading, Popup, Toast } from 'antd-mobile'
import { projectApi, taskApi } from '../../api/task.js'
// 跨项目拷贝弹层 (T-CP-*): 选择目标项目 → 深拷贝; 人员不属于目标项目时丢弃并提示
export default function CopyTaskPopup({ task, visible, onClose, onDone }) {
const [projects, setProjects] = useState(null)
useEffect(() => {
if (!visible) return
setProjects(null)
projectApi.list()
.then((d) => setProjects((d.items || []).filter((p) => p.id !== task.projectId && !p.archived)))
.catch((e) => {
Toast.show({ content: e.message })
setProjects([])
})
}, [visible, task.projectId])
async function doCopy(projectId, name) {
try {
const r = await taskApi.copy(task.id, projectId)
const dropped = r.dropped || {}
const lost = [dropped.assignee?.name, ...(dropped.members || []).map((m) => m.name)].filter(Boolean)
Toast.show({
content: lost.length
? `已拷贝到「${name}」;${lost.join('、')} 不是该项目成员,已不保留`
: `已拷贝到「${name}」`,
})
onClose?.()
onDone?.()
} catch (e) {
Toast.show({ content: e.message })
}
}
return (
<Popup
visible={visible}
onMaskClick={onClose}
bodyStyle={{
borderTopLeftRadius: 12, borderTopRightRadius: 12,
padding: '16px 16px calc(env(safe-area-inset-bottom, 0px) + 20px)',
}}
>
<div className="sheet-title">跨项目拷贝</div>
<div className="sheet-sub">「{task.title}」→ 选择目标项目;源任务保持不变</div>
{!projects && <div className="empty" style={{ padding: '20px 0' }}><DotLoading color="#1677ff" /> 加载项目…</div>}
{projects && projects.length === 0 && <div className="extras-empty">没有其他可拷贝的项目</div>}
{(projects || []).map((p) => (
<div className="pick-row" key={p.id} onClick={() => doCopy(p.id, p.name)}>
<div className="avatar">{(p.name || '?').slice(0, 1)}</div>
<span className="pn">{p.name}</span>
<span className="link">拷贝</span>
</div>
))}
<div className="btn-ghost" onClick={onClose}>取消</div>
</Popup>
)
}
+142
View File
@@ -0,0 +1,142 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { getRecentIds, getRecentOrder, setRecentOrder } from '../../pages/task/prefs.js'
// 首屏最近项目条 (T-VW-08/10)
// - 展示最近使用的 3 个项目 + 「更多项目」入口
// - 顺序: 手动排序(recentOrder)优先, 其次最近访问(recentIds), 兜底列表顺序
// - 长按卡片 300ms 进入拖拽态, 水平拖动实时换位, 松手持久化(设备端)
const COLORS = ['#1677ff', '#00b578', '#ff8f1f', '#a855f7', '#14b8a6', '#f43f9e']
const colorOf = (id) => COLORS[Math.abs(id) % COLORS.length]
const LONG_PRESS = 300
const MOVE_TOLERANCE = 8
export default function RecentProjects({ projects, currentId, onSelect, onMore }) {
const [ver, setVer] = useState(0) // 手动排序后强制重算
const [dragIdx, setDragIdx] = useState(-1) // 拖拽中的卡片下标 (视觉态)
const dragIdxRef = useRef(-1) // 监听器闭包读的下标 (state 在闭包里是旧值)
const barRef = useRef(null)
const timer = useRef(null)
const start = useRef(null)
const active = useRef(false)
// 展示顺序: 手动 > 自动 > 列表序, 只取未归档的前 3
const shown = useMemo(() => {
if (!projects) return []
const score = new Map()
getRecentOrder().forEach((id, i) => score.set(id, i))
let n = score.size
getRecentIds().forEach((id) => { if (!score.has(id)) score.set(id, n++) })
const fallback = n + 1000
return [...projects]
.filter((p) => !p.archived)
.sort((a, b) => (score.get(a.id) ?? fallback) - (score.get(b.id) ?? fallback))
.slice(0, 3)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [projects, ver])
useEffect(() => () => cleanup(), [])
function cleanup() {
if (timer.current) { clearTimeout(timer.current); timer.current = null }
active.current = false
start.current = null
document.removeEventListener('touchmove', onMove, { passive: false })
document.removeEventListener('touchend', onEnd)
document.removeEventListener('mousemove', onMove)
document.removeEventListener('mouseup', onEnd)
}
const pointOf = (ev) => {
const t = ev.touches?.[0] || ev.changedTouches?.[0] || ev
return { x: t.clientX, y: t.clientY }
}
// 按横坐标计算落点下标 (卡片宽 96 + 间距 8)
function indexAt(x, fallback = -1) {
const rect = barRef.current?.getBoundingClientRect()
if (!rect) return fallback
const rel = x - rect.left + 4
return Math.max(0, Math.min(shown.length - 1, Math.floor(rel / 104)))
}
function onMove(ev) {
const p = pointOf(ev)
if (!active.current) {
// 未激活前: 移动超容差则取消长按 (用户想滚动)
if (start.current && (Math.abs(p.x - start.current.x) > MOVE_TOLERANCE || Math.abs(p.y - start.current.y) > MOVE_TOLERANCE)) {
if (timer.current) { clearTimeout(timer.current); timer.current = null }
}
return
}
if (ev.cancelable) ev.preventDefault()
// 用 ref 取当前拖拽下标: 监听器闭包里的 state 是旧值
const from = dragIdxRef.current
const to = indexAt(p.x, from)
if (to !== from && to >= 0 && from >= 0) {
// 实时换位: 重写设备端顺序, ver 触发重算 (shown 由 memo 派生)
const ids = shown.map((p2) => p2.id)
const [moved] = ids.splice(from, 1)
ids.splice(to, 0, moved)
setRecentOrder(ids)
dragIdxRef.current = to
setDragIdx(to)
setVer((v) => v + 1)
}
}
function onEnd() {
if (active.current) setVer((v) => v + 1)
cleanup()
dragIdxRef.current = -1
setDragIdx(-1)
}
function beginPress(i, ev) {
if (ev.button && ev.button !== 0) return
const p = pointOf(ev)
start.current = p
active.current = false
cleanup()
dragIdxRef.current = i
timer.current = setTimeout(() => {
active.current = true
setDragIdx(i)
if (navigator.vibrate) navigator.vibrate(10)
}, LONG_PRESS)
document.addEventListener('touchmove', onMove, { passive: false })
document.addEventListener('touchend', onEnd)
document.addEventListener('mousemove', onMove)
document.addEventListener('mouseup', onEnd)
}
if (!projects) return null
return (
<>
<div className="rp-bar" ref={barRef}>
{shown.map((p, i) => (
<div
key={p.id}
className={`rp-card${p.id === currentId ? ' on' : ''}${i === dragIdx ? ' dragging' : ''}`}
data-project-id={p.id}
onTouchStart={(ev) => beginPress(i, ev)}
onMouseDown={(ev) => beginPress(i, ev)}
onClick={() => { if (!active.current && dragIdx < 0) onSelect?.(p.id) }}
>
<div className="rp-ico" style={{ background: colorOf(p.id) }}>
{(p.name || '?').slice(0, 1)}
</div>
<div className="rp-name">{p.name}</div>
<div className="rp-meta">{p.taskCount ?? 0} 个任务</div>
<div className="rp-grip">⠿</div>
</div>
))}
<div className="rp-more" onClick={onMore}>
<div className="rp-more-ico">▤</div>
更多项目
</div>
</div>
<div className="rp-drag-tip">长按卡片可调整顺序(本机记忆)</div>
</>
)
}
+8
View File
@@ -0,0 +1,8 @@
// 任务状态小圆点 / 药丸标记 (T-ST-09)
const LABEL = { todo: '未开始', doing: '执行中', done: '完成' }
export default function StatusDot({ status = 'todo', pill = false, className = '' }) {
const cls = `sdot ${status}${pill ? ' pill' : ''} ${className}`.trim()
if (pill) return <span className={cls}>{LABEL[status] || ''}</span>
return <span className={cls} title={LABEL[status] || ''} />
}
+66
View File
@@ -0,0 +1,66 @@
import StatusDot from './StatusDot.jsx'
// 看板卡片 (T-UI-02): 轻量化
// - 标题 + 状态点 (T-ST-09)
// - 负责人 12px/600 突出 (T-AS-01); 任务成员 10.5px 弱化置于下方 (T-AS-02)
// - 子任务内嵌简洁展示 + 可直接勾选 (T-ST-06/07)
const MAX_SUB = 3
export default function TaskCard({
task, bindDrag, onClick, onToggleSub, onToggle, onMore, dragging = false, highlight = false,
}) {
const children = task.children || []
const shown = children.slice(0, MAX_SUB)
const rest = children.length - shown.length
return (
<div
className={`bcard${dragging ? ' dragging' : ''}${highlight ? ' hl' : ''}`}
data-task-id={task.id}
onClick={onClick}
{...(bindDrag ? bindDrag(task) : {})}
>
<div className="brow1">
<span className="btitle">{task.title}</span>
<span
className="bstat"
onClick={(e) => { e.stopPropagation(); onToggle?.(task) }}
onPointerDown={(e) => e.stopPropagation()}
title="点按切换完成"
>
<StatusDot status={task.status} />
</span>
</div>
<div className="bmeta">
<span className={`bassignee${task.assignee ? '' : ' none'}`}>
{task.assignee?.name || '未指派'}
</span>
{task.members?.length > 0 && (
<span className="bmembers">+ {task.members.map((m) => m.name).join('、')}</span>
)}
{onMore && (
<span
className="bmore-btn"
onClick={(e) => { e.stopPropagation(); onMore(task) }}
onPointerDown={(e) => e.stopPropagation()}
>⋯</span>
)}
</div>
{shown.map((c) => (
<div
key={c.id}
className={`bsub${c.status === 'done' ? ' done' : ''}`}
onClick={(e) => { e.stopPropagation(); onToggleSub?.(c) }}
onPointerDown={(e) => e.stopPropagation()}
>
<span className="sbox" />
<span className="stitle">{c.title}</span>
<StatusDot status={c.status} />
</div>
))}
{rest > 0 && <div className="bmore">+{rest} 个子任务</div>}
</div>
)
}
+68
View File
@@ -0,0 +1,68 @@
import StatusDot from './StatusDot.jsx'
// 列表视图的一行 (Todoist 风格, T-UI-01)
// 点击行体 → 打开详情; 长按 400ms 后左右拖动换列 (bindDrag 来自 useRowDrag)
// 复选框 / ⋯ 是独立控件: pointerdown 不冒泡, 不发起拖动,
// 避免长按行后抬手时合成的 click 落在控件上误触状态切换
export default function TaskRow({
task, onToggle, onClick, onMore, bindDrag, dragTarget, indent = false, children,
}) {
const done = task.status === 'done'
return (
<>
<div
className={`${indent ? 'sub-row' : 'task-row'}${done ? ' done' : ''}${dragTarget ? ' dragging' : ''}`}
data-task-id={indent ? undefined : task.id}
onClick={onClick}
{...(!indent && bindDrag ? bindDrag(task) : {})}
>
<div
className="box"
onClick={(e) => { e.stopPropagation(); onToggle?.(task) }}
onPointerDown={(e) => e.stopPropagation()}
>
<svg viewBox="0 0 14 14" width={indent ? 10 : 11} height={indent ? 10 : 11}>
<path d="M2.5 7.2l3 3 6-6.4" stroke="#fff" strokeWidth="2" fill="none" strokeLinecap="round" />
</svg>
</div>
{indent ? (
<>
<span className="sub-title">{task.title}</span>
<StatusDot status={task.status} pill />
</>
) : (
<>
<div className="task-main">
<div className="task-title">{task.title}</div>
<div className="task-meta">
<StatusDot status={task.status} />
<span>{task.status === 'done' ? '完成' : task.status === 'doing' ? '执行中' : '未开始'}</span>
{task.column?.name && <span className="col">{task.column.name}</span>}
<span className={`assignee${task.assignee ? '' : ' none'}`}>
{task.assignee?.name || '未指派'}
</span>
{task.members?.length > 0 && (
<span className="members">+ {task.members.map((m) => m.name).join('、')}</span>
)}
</div>
</div>
<div className="task-right">
{dragTarget && (
<span className={`drag-pill${dragTarget.id === task.columnId ? ' cur' : ''}`}>
→ {dragTarget.name}
</span>
)}
<span
className="more"
onClick={(e) => { e.stopPropagation(); onMore?.(task) }}
onPointerDown={(e) => e.stopPropagation()}
>⋯</span>
</div>
</>
)}
</div>
{children}
</>
)
}
+66
View File
@@ -0,0 +1,66 @@
import { useEffect, useState } from 'react'
import { Popup, Toast } from 'antd-mobile'
// 人员选择 (T-AS-03/04): 只能从"当前项目成员"中选
export default function UserPicker({
visible, members = [], value, multiple = false, title = '选择负责人',
allowEmpty = true, onClose, onConfirm,
}) {
const [sel, setSel] = useState(multiple ? [] : null)
useEffect(() => {
if (!visible) return
setSel(multiple ? (value || []) : (value ?? null))
}, [visible, value, multiple])
const toggle = (uid) => {
if (multiple) {
setSel((prev) => {
const arr = Array.isArray(prev) ? prev : []
return arr.includes(uid) ? arr.filter((x) => x !== uid) : [...arr, uid]
})
} else {
setSel((prev) => (prev === uid ? null : uid))
}
}
const confirm = () => {
if (!multiple && sel === null && !allowEmpty) {
Toast.show({ content: '请选择负责人' })
return
}
onConfirm?.(sel)
onClose?.()
}
return (
<Popup visible={visible} onMaskClick={onClose} bodyStyle={{ borderTopLeftRadius: 12, borderTopRightRadius: 12, padding: '16px 16px calc(env(safe-area-inset-bottom, 0px) + 20px)' }}>
<div className="sheet-title">{title}</div>
<div className="sheet-sub">仅可选择当前项目的成员</div>
{members.length === 0 && <div className="extras-empty">项目暂无成员</div>}
{members.map((m) => {
const on = multiple ? (Array.isArray(sel) && sel.includes(m.userId)) : sel === m.userId
return (
<div className="pick-row" key={m.userId} onClick={() => toggle(m.userId)}>
<div className="avatar">{(m.name || '?').slice(0, 1)}</div>
<span className="pn">{m.name}</span>
<span className={`ck${on ? ' on' : ''}`} />
</div>
)
})}
{allowEmpty && !multiple && (
<div className="pick-row" onClick={() => setSel(null)}>
<div className="avatar" style={{ background: '#f2f3f5', color: '#b6bcc4' }}>—</div>
<span className="pn">清除负责人</span>
<span className={`ck${sel === null ? ' on' : ''}`} />
</div>
)}
<div className="btn-primary" onClick={confirm}>确定</div>
<div className="btn-ghost" onClick={onClose}>取消</div>
</Popup>
)
}
+123
View File
@@ -0,0 +1,123 @@
import { useCallback, useEffect, useRef, useState } from 'react'
// 看板卡片列拖动 (重点设计 §7.1)
// 方案要点:
// 1. Pointer Events + setPointerCapture —— 触摸/鼠标统一, 捕获后不丢事件
// 2. 长按 400ms 激活 (激活前移动 >10px 视为滚动, 取消本次拖动)
// 3. 激活后卡片原位抬起 (不跟随手指, 无浮层/无任务名)
// 4. 拖动中持续 elementFromPoint 命中 [data-column-id] 高亮目标列;
// 指针靠近容器左右边缘时 rAF 自动横滚, 可拖到屏幕外的列
// 5. 松手落到目标列 (≠原列) 才提交; pointercancel (浏览器接管手势,
// 如触发纵向滚动) 只放弃不提交
// 6. 防误触: 卡片禁长按系统菜单(contextmenu); 拖动结束后 350ms 内的
// click 视为拖动余波, 不打开详情 (justEnded)
export default function useColumnDrag({ onDrop, scrollSelector = '.board-wrap' } = {}) {
const [dragTask, setDragTask] = useState(null) // 拖动中的任务 (视觉态)
const [overCol, setOverCol] = useState(null) // 高亮的目标列 id
const st = useRef({ timer: null, active: false, startX: 0, startY: 0, task: null, autoScroll: 0, raf: 0 })
const lastEnd = useRef(0) // 拖动结束时刻
const stopAutoScroll = () => {
st.current.autoScroll = 0
if (st.current.raf) { cancelAnimationFrame(st.current.raf); st.current.raf = 0 }
}
const cleanup = useCallback(() => {
if (st.current.timer) { clearTimeout(st.current.timer); st.current.timer = null }
st.current.active = false
stopAutoScroll()
setDragTask(null)
setOverCol(null)
}, [])
useEffect(() => () => cleanup(), [cleanup])
/** 拖动刚结束 (350ms 内): 抑制松手合成的 click 误开详情页 */
const justEnded = useCallback(() => Date.now() - lastEnd.current < 350, [])
const hitColumn = (x, y) => {
const el = document.elementFromPoint(x, y)
const colEl = el?.closest?.('[data-column-id]')
return colEl ? Number(colEl.dataset.columnId) : null
}
// 边缘自动滚动 (±36px 触发, 每帧 10px)
const autoScroll = (container, clientX) => {
if (!container) { stopAutoScroll(); return }
const r = container.getBoundingClientRect()
const edge = 36
let dir = 0
if (clientX < r.left + edge) dir = -1
else if (clientX > r.right - edge) dir = 1
st.current.autoScroll = dir
if (!dir) { stopAutoScroll(); return }
if (st.current.raf) return
const step = () => {
if (!st.current.autoScroll) return
container.scrollLeft += st.current.autoScroll * 10
st.current.raf = requestAnimationFrame(step)
}
st.current.raf = requestAnimationFrame(step)
}
const onPointerMove = (ev) => {
const s = st.current
if (!s.active) {
if (s.timer && (Math.abs(ev.clientX - s.startX) > 10 || Math.abs(ev.clientY - s.startY) > 10)) {
clearTimeout(s.timer); s.timer = null
}
return
}
ev.preventDefault()
const col = hitColumn(ev.clientX, ev.clientY)
setOverCol(col)
autoScroll(document.querySelector(scrollSelector), ev.clientX)
}
const onPointerUp = (ev) => {
const s = st.current
if (s.active) {
lastEnd.current = Date.now()
const col = hitColumn(ev.clientX, ev.clientY)
if (col && s.task && col !== s.task.columnId) onDrop?.(s.task, col)
}
cleanup()
}
/** 绑定到卡片: 需要卡片元素设置 touch-action: pan-y */
const bind = useCallback((task) => ({
onPointerDown: (ev) => {
if (ev.button !== undefined && ev.button !== 0) return
st.current = { ...st.current, startX: ev.clientX, startY: ev.clientY, task, active: false }
// 长按计时: 期间移动超容差(用户在滚动)则取消
st.current.timer = setTimeout(() => {
st.current.active = true
setDragTask(task)
if (navigator.vibrate) navigator.vibrate(15)
}, 400)
// 捕获: 后续 move/up/cancel 都派发给该元素, 不丢
try { ev.currentTarget.setPointerCapture(ev.pointerId) } catch { /* ignore */ }
const el = ev.currentTarget
const move = (e2) => onPointerMove(e2)
const up = (e2) => { onPointerUp(e2); detach() }
const cancel = () => {
if (st.current.active) lastEnd.current = Date.now()
cleanup()
detach()
}
const detach = () => {
el.removeEventListener('pointermove', move)
el.removeEventListener('pointerup', up)
el.removeEventListener('pointercancel', cancel)
}
el.addEventListener('pointermove', move)
el.addEventListener('pointerup', up)
el.addEventListener('pointercancel', cancel)
},
// 长按不弹系统菜单 (配合 CSS user-select:none, 防长按变文字选择)
onContextMenu: (ev) => ev.preventDefault(),
// eslint-disable-next-line react-hooks/exhaustive-deps
}), [])
return { dragTask, overCol, bind, cancel: cleanup, justEnded }
}
+98
View File
@@ -0,0 +1,98 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
// 列表视图卡片拖动换列 (2026-10-24, 替代「长按 → ActionSheet」:
// 旧方案长按与复选框/行点击互相误触, 且菜单弹出时手指还按在屏上, 体验割裂)
// 手势与看板一致: 长按 400ms 行原位抬起(震动), 之后左右拖动;
// 水平位移按 80px/列 步进切换目标列 (顺序同列表分组), 行尾实时显示
// 目标列名徽标, 松手落到目标列 (≠当前列才提交)。
// touch-action: pan-y —— 横向位移归拖动, 纵向仍归列表滚动;
// 激活后浏览器若接管纵向滚动 (pointercancel) 只放弃不提交。
// 注: 列表只展示有任务的列, 空列拖不进去 (用看板视图可拖空列)。
const STEP = 80
export default function useRowDrag({ columns, onDrop }) {
// { task, fromIdx, toIdx }: 拖动中的行 + 当前目标列下标 (徽标渲染)
const [drag, setDrag] = useState(null)
const st = useRef({ timer: null, active: false, startX: 0, startY: 0, task: null, fromIdx: 0, toIdx: 0 })
const lastEnd = useRef(0) // 拖动结束时刻
// 目标列顺序 = 列表分组顺序 (sortOrder)
const ordered = useMemo(
() => [...(columns || [])].sort((a, b) => a.sortOrder - b.sortOrder),
[columns],
)
const cleanup = useCallback(() => {
if (st.current.timer) { clearTimeout(st.current.timer); st.current.timer = null }
st.current.active = false
setDrag(null)
}, [])
useEffect(() => () => cleanup(), [cleanup])
/** 拖动刚结束 (350ms 内): 抑制松手合成的 click 误开详情页 */
const justEnded = useCallback(() => Date.now() - lastEnd.current < 350, [])
const onMove = (ev) => {
const s = st.current
if (!s.active) {
if (s.timer && (Math.abs(ev.clientX - s.startX) > 10 || Math.abs(ev.clientY - s.startY) > 10)) {
clearTimeout(s.timer); s.timer = null
}
return
}
ev.preventDefault()
const to = Math.max(0, Math.min(ordered.length - 1, s.fromIdx + Math.round((ev.clientX - s.startX) / STEP)))
if (to !== s.toIdx) {
s.toIdx = to
setDrag({ task: s.task, fromIdx: s.fromIdx, toIdx: to })
}
}
const onUp = () => {
const s = st.current
if (s.active && s.task) {
lastEnd.current = Date.now()
const target = ordered[s.toIdx]
if (target && target.id !== s.task.columnId) onDrop?.(s.task, target.id)
}
cleanup()
}
/** 绑定到列表行: 行元素需设置 touch-action: pan-y */
const bind = useCallback((task) => ({
onPointerDown: (ev) => {
if (ev.button !== undefined && ev.button !== 0) return
const fromIdx = ordered.findIndex((c) => c.id === task.columnId)
if (fromIdx < 0) return // 不在任何列 (如「其他」组) 的任务不支持拖动
st.current = { ...st.current, startX: ev.clientX, startY: ev.clientY, task, fromIdx, toIdx: fromIdx, active: false }
st.current.timer = setTimeout(() => {
st.current.active = true
if (navigator.vibrate) navigator.vibrate(15)
setDrag({ task, fromIdx, toIdx: fromIdx })
}, 400)
try { ev.currentTarget.setPointerCapture(ev.pointerId) } catch { /* ignore */ }
const el = ev.currentTarget
const move = (e2) => onMove(e2)
const up = () => { onUp(); detach() }
const cancel = () => {
if (st.current.active) lastEnd.current = Date.now()
cleanup()
detach()
}
const detach = () => {
el.removeEventListener('pointermove', move)
el.removeEventListener('pointerup', up)
el.removeEventListener('pointercancel', cancel)
}
el.addEventListener('pointermove', move)
el.addEventListener('pointerup', up)
el.addEventListener('pointercancel', cancel)
},
// 长按不弹系统菜单 (配合 CSS user-select:none, 防长按变文字选择)
onContextMenu: (ev) => ev.preventDefault(),
// eslint-disable-next-line react-hooks/exhaustive-deps
}), [ordered, onDrop])
return { drag, bind, cancel: cleanup, justEnded }
}
+27
View File
@@ -0,0 +1,27 @@
import { useEffect, useState } from 'react'
// 键盘弹出时的"插入量": window.innerHeight 与 visualViewport.height 的差
// Android WebView 键盘弹出时 visualViewport 变小; 用它把底部弹层的内容抬到键盘上方,
// 避免输入框(如任务详情里的"添加子任务")被键盘遮挡。
// 阈值 90px 过滤掉地址栏收起之类的轻微抖动; 无 visualViewport 时恒为 0 (iOS 原生键盘通常整体缩放, 无需处理)。
export default function useKeyboardInset() {
const [inset, setInset] = useState(0)
useEffect(() => {
const vv = window.visualViewport
if (!vv) return
const onChange = () => {
const diff = window.innerHeight - vv.height - vv.offsetTop
setInset(diff > 90 ? Math.round(diff) : 0)
}
vv.addEventListener('resize', onChange)
vv.addEventListener('scroll', onChange)
onChange()
return () => {
vv.removeEventListener('resize', onChange)
vv.removeEventListener('scroll', onChange)
}
}, [])
return inset
}
+24
View File
@@ -0,0 +1,24 @@
import { useEffect } from 'react'
// 弹层 / 全屏覆盖层打开时拦截返回键: 先关弹层, 而不是离开当前路由 (bug: 返回跑到上一个 Tab)
// 原理: 打开时 pushState 一条哨兵记录 (URL 不变), 返回键先消费它;
// popstate => 视为"关闭弹层"。正常关闭 (点 X / 遮罩) 时在 cleanup 里 history.back() 撤掉哨兵。
// 注意: close 必须把 open 置 false, 否则 cleanup 不会执行、哨兵残留。
export default function useOverlayBack(open, close) {
useEffect(() => {
if (!open) return
let closedByBack = false
window.history.pushState({ __overlay: true }, '')
const onPop = () => {
closedByBack = true
close()
}
window.addEventListener('popstate', onPop)
return () => {
window.removeEventListener('popstate', onPop)
// 正常关闭: 撤销哨兵 (不产生可见的后退, 因为 URL 未变)
if (!closedByBack) window.history.back()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open])
}
+8 -5
View File
@@ -1,13 +1,16 @@
import { useEffect, useState } from 'react'
import { Outlet, useLocation } from 'react-router-dom'
import { SafeArea } from 'antd-mobile'
import BottomNav from '../components/BottomNav.jsx'
// 移动端应用外壳
// - 顶部 SafeArea: 让出手机状态栏高度
// - 顶部安全区: 用 --safe-top 撑开 (由 main.jsx 的 calibrateSafeTop 实测校准)
// - 中间 <main>: flex:1 可滚动主区
// - 底部 TabBar: antd-mobile TabBar 自带底部安全区
// 解决: 状态栏遮挡/底部 TabBar 遮挡/左右贴边
//
// 注意: 这里不再用 antd-mobile 的 <SafeArea position="top" />.
// 它只是一个空 div, 高度完全依赖 env(safe-area-inset-top); 而 Android WebView
// (Tauri) 下该变量常为 0, 会导致内容顶到状态栏底下 —— V0.2 把 45px 的 NavBar
// 换成 23px 的 PageHeader 后这个问题就暴露了. 改用可控高度的 div + JS 校准.
export default function MobileShell() {
const location = useLocation()
// pathname 变化时给页面容器加动画 class (仅播放入场动画, 不重挂载子树,
@@ -21,7 +24,7 @@ export default function MobileShell() {
return (
<div className="app-root">
<SafeArea position="top" />
<div className="app-safe-top" aria-hidden="true" />
<main className="app-main">
<div className={'page-transition' + (animating ? ' active' : '')}>
<Outlet />
@@ -30,4 +33,4 @@ export default function MobileShell() {
<BottomNav />
</div>
)
}
}
+45
View File
@@ -14,6 +14,51 @@ import './styles/globals.css'
// - 异步执行, 不阻塞 React 挂载
initOta()
// ==================== 顶部状态栏安全区校准 ====================
// 背景: Android WebView (Tauri) 下 env(safe-area-inset-top) 常返回 0,
// 而 antd-mobile 的 <SafeArea> 只是个依赖该变量的空 div, 于是内容会顶到状态栏下.
// 这里用实测值覆盖 --safe-top, 保证不同机型/不同原生配置下都能正确让位.
function calibrateSafeTop() {
const root = document.documentElement
// env() 的真实值必须用一个临时元素测量, 不能直接读 --safe-top ——
// 该变量是 max(env(...), 0px) 这样的表达式, parseFloat 会得到 NaN.
const probe = document.createElement('div')
probe.style.cssText =
'position:absolute;left:-9999px;top:0;width:1px;height:0;' +
'padding-top:env(safe-area-inset-top, 0px);visibility:hidden;pointer-events:none;'
document.body.appendChild(probe)
const envTop = parseFloat(getComputedStyle(probe).paddingTop) || 0
document.body.removeChild(probe)
// 屏幕顶部与 WebView 可视区顶部的差值 = 状态栏(及刘海)高度.
// 优先用 visualViewport (能反映真实可视区域), 回退 innerHeight.
const screenH = window.screen?.height || 0
const viewH = window.visualViewport?.height || window.innerHeight || 0
const screenW = window.screen?.width || 0
const viewW = window.visualViewport?.width || window.innerWidth || 0
// 只在"纵向且高度差合理"时采用, 避免横屏/桌面窗口误判
const isPortrait = viewW <= viewH
const diff = screenH - viewH
const measured = isPortrait && diff > 0 && diff < 120 ? diff : 0
// 取 env() 与实测值的较大者; 两者都失效时给 24px 保底
// (多数 Android 状态栏在 24~28px, 宁可多一点也不能压字)
let top = Math.max(envTop, measured)
if (top < 4) top = 24 // env 与实测都没拿到 → 用保守兜底, 保证不遮挡
root.style.setProperty('--safe-top', top + 'px')
}
// DOM 就绪后再校准 (需要往 body 插探测元素); 同步先跑一次保证首屏不闪
if (document.body) {
calibrateSafeTop()
} else {
document.addEventListener('DOMContentLoaded', calibrateSafeTop, { once: true })
}
// 旋转屏幕 / 视口变化时重算
window.addEventListener('orientationchange', () => setTimeout(calibrateSafeTop, 120))
window.visualViewport?.addEventListener('resize', calibrateSafeTop)
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<BrowserRouter>
-231
View File
@@ -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>
)
}
+32 -14
View File
@@ -1,21 +1,30 @@
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 } from 'antd-mobile'
import PageHeader from '../components/PageHeader.jsx'
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 (油价 / 经济数据 / 策略研究 / 经济文章), 默认 油价
// /macro → 显示 oil tab (本组件 activeFromPath 决定)
// /macro/oil → 油价 (新增)
// 宏观: 顶栏 6 tab, 默认 新闻
// /macro → 显示 news tab (activeFromPath 决定)
// /macro/alerts → 报警 (V0.2: 原「首页」tab 合并进来, 置最左)
// /macro/news → 新闻 (V0.2: 原「首页」tab 合并进来, 置第二并作为默认)
// /macro/oil → 油价
// /macro/data → 经济数据
// /macro/strategy → 策略研究
// /macro/articles → 经济文章
// /macro/articles/:slug → 文章详情
// 详情: /macro/data/:name · /macro/strategy/:name · /macro/articles/:slug · /macro/news/:id
// 说明: tab 数量已超出屏宽, SwipeTabs 会在右侧显示渐变 + 箭头提示可继续滑动
const TABS = [
{ key: 'alerts', label: '报警', path: '/macro/alerts' },
{ key: 'news', label: '新闻', path: '/macro/news' },
{ key: 'oil', label: '油价', path: '/macro/oil' },
{ key: 'data', label: '经济数据', path: '/macro/data' },
{ key: 'strategy', label: '策略研究', path: '/macro/strategy' },
@@ -23,27 +32,35 @@ const TABS = [
]
function activeFromPath(pathname) {
if (pathname.startsWith('/macro/alerts')) return 'alerts'
if (pathname.startsWith('/macro/news')) return 'news'
if (pathname.startsWith('/macro/oil')) return 'oil'
if (pathname.startsWith('/macro/articles')) return 'articles'
if (pathname.startsWith('/macro/strategy')) return 'strategy'
if (pathname.startsWith('/macro/data')) return 'data'
// 父路由 /macro 或 / 走 Tab 列表第一个 (油价)
return 'oil'
if (pathname.startsWith('/macro/strategy')) return 'strategy'
if (pathname.startsWith('/macro/articles')) return 'articles'
// 父路由 /macro 走默认 tab (新闻)
return 'news'
}
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,20 +70,21 @@ 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>
)
}
return (
<div className="page-with-navbar">
<NavBar back={null} />
<PageHeader title="宏观" />
<SwipeTabs tabs={tabs} active={active} onChange={onChange} />
</div>
)
+39 -4
View File
@@ -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, 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 升级区
@@ -23,12 +25,22 @@ export default function MePage() {
const { user, logout } = useAuth()
const navigate = useNavigate()
// 顶部安全区与「我」页蓝色渐变头部无缝衔接: 给 body 打标记,
// 由 CSS 让 .app-safe-top 也使用同一条渐变, 避免出现一条白边/色块把蓝色割断.
// 离开本页时移除标记, 其他页恢复默认底色.
useEffect(() => {
document.body.classList.add('me-safe-top')
return () => { document.body.classList.remove('me-safe-top') }
}, [])
// OTA 状态
const [otaState, setOtaState] = useState('idle') // idle | checking | available | none | downloading | ready | error
const [otaInfo, setOtaInfo] = useState(null) // 检查结果中的 info
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 +55,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')
@@ -111,7 +123,6 @@ export default function MePage() {
return (
<div className="me-page">
<NavBar back={null} className="me-navbar" />
<div className="me-profile">
<Avatar
className="me-avatar"
@@ -130,6 +141,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 +244,4 @@ export default function MePage() {
<div className="me-version">v{APP_VERSION} · build {BUILD_ID}</div>
</div>
)
}
}
+3 -2
View File
@@ -1,7 +1,8 @@
import { useEffect, useState, useCallback } from 'react'
import { useNavigate } from 'react-router-dom'
import { NavBar, Card, Empty, DotLoading } from 'antd-mobile'
import { Card, Empty, DotLoading } from 'antd-mobile'
import { api } from '../api/client.js'
import PageHeader from '../components/PageHeader.jsx'
import SwipeTabs from '../components/SwipeTabs.jsx'
import useRefreshOnVisible from '../hooks/useRefreshOnVisible.js'
import EnvView from './monitor/EnvView.jsx'
@@ -46,7 +47,7 @@ export default function MonitorPage() {
]
return (
<div className="page-with-navbar">
<NavBar back={null} />
<PageHeader title="监控" />
<div className="monitor-content">
<SwipeTabs tabs={tabs} active={active} onChange={setActive} />
</div>
+173
View File
@@ -0,0 +1,173 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { Button, DotLoading, Empty, Segmented, Toast } from 'antd-mobile'
import { Celebrate } from '../../components/RingProgress.jsx'
import { checkinApi } from '../../api/checkin.js'
import useRefreshOnVisible from '../../hooks/useRefreshOnVisible.js'
import {
toDateStr, addDays, addMonths, monthGrid, weekdayLabels, dayLabel, fmtMonth,
} from './date.js'
// 日历视图: 月历网格 + 今天/昨天/明天 三视图 + 日期标记 (F-CL-*)
export default function CalendarView({ date, onDateChange }) {
const today = toDateStr()
const [month, setMonth] = useState(() => date.slice(0, 7))
const [cal, setCal] = useState(null) // 月历数据
const [detail, setDetail] = useState(null) // 选中日详情
const [err, setErr] = useState('')
const [bursts, setBursts] = useState({})
const loadCal = useCallback((silent) => {
if (!silent) setErr('')
checkinApi.calendar(month)
.then(setCal)
.catch((e) => { if (!silent) setErr(e.message) })
}, [month])
const loadDetail = useCallback((silent) => {
checkinApi.day(date).then(setDetail).catch(() => {})
}, [date])
useEffect(() => { loadCal(false) }, [loadCal])
useEffect(() => { setDetail(null); loadDetail(false) }, [loadDetail])
useRefreshOnVisible(() => { loadCal(true); loadDetail(true) })
// 切换日期时, 若跨月则同步月份
useEffect(() => {
const m = date.slice(0, 7)
setMonth((cur) => (cur === m ? cur : m))
}, [date])
const days = useMemo(() => monthGrid(month), [month])
const byDate = useMemo(() => {
const map = new Map()
;(cal?.days || []).forEach((d) => map.set(d.date, d))
return map
}, [cal])
const quickValue = useMemo(() => {
if (date === today) return 0
if (date === addDays(today, -1)) return -1
if (date === addDays(today, 1)) return 1
return null
}, [date, today])
async function checkFromCalendar(item) {
if (!item.canCheck) return
const snapshot = detail
setDetail((d) => ({
...d,
items: d.items.map((x) => {
if (x.id !== item.id) return x
const done = x.done + 1
const countOnDate = (x.countOnDate || 0) + 1
return {
...x, done, countOnDate,
percent: Math.min(1, done / x.target),
completed: done >= x.target,
over: Math.max(0, done - x.target),
dayDone: countOnDate >= x.expectedPerDay,
}
}),
}))
try {
const res = await checkinApi.check(item.id, date)
const fresh = await checkinApi.day(date)
setDetail(fresh)
if (res.item.completed) setBursts((m) => ({ ...m, [item.id]: (m[item.id] || 0) + 1 }))
loadCal(true)
} catch (e) {
setDetail(snapshot)
Toast.show({ content: e.message || '打卡失败' })
}
}
const items = detail?.items || []
const isFuture = !!detail?.isFuture
return (
<div className="calendar-view">
<div className="cal-month-bar">
<span className="cal-arrow" onClick={() => setMonth((m) => addMonths(m, -1))}>‹</span>
<span className="cal-month">{fmtMonth(month)}</span>
<span className="cal-arrow" onClick={() => setMonth((m) => addMonths(m, 1))}>›</span>
<span className="cal-today-btn" onClick={() => onDateChange(today)}>回到今天</span>
</div>
<div className="cal-quick">
<Segmented
options={[
{ label: '昨天', value: -1 },
{ label: '今天', value: 0 },
{ label: '明天', value: 1 },
]}
value={quickValue}
onChange={(v) => onDateChange(addDays(today, Number(v)))}
/>
</div>
{err && <div className="error-banner">{err}<span className="retry-link" onClick={() => loadCal(false)}>重试</span></div>}
<div className="cal-grid">
<div className="cal-weekday">
{weekdayLabels().map((w) => <span key={w}>{w}</span>)}
</div>
<div className="cal-days">
{days.map((d) => {
const info = byDate.get(d)
const inMonth = d.slice(0, 7) === month
const status = info?.status || 'empty'
return (
<div
key={d}
className={
'cal-day' + (inMonth ? '' : ' out') + (d === date ? ' selected' : '') +
(info?.isToday ? ' today' : '') + (info?.isFuture ? ' future' : '')
}
onClick={() => onDateChange(d)}
>
<span className="cal-day-num">{Number(d.slice(8, 10))}</span>
{inMonth && status !== 'empty' && (
<span className={'cal-dot ' + status} />
)}
</div>
)
})}
</div>
</div>
{cal && (
<div className="cal-rate">本月完成率 <strong>{Math.round((cal.rate || 0) * 100)}%</strong></div>
)}
<div className="cal-detail">
<div className="cal-detail-title">
{dayLabel(date, today)} · {date}
{isFuture && <span className="habit-badge">计划(不可打卡)</span>}
</div>
{detail === null && <div className="monitor-loading"><DotLoading /> 加载中...</div>}
{detail !== null && items.length === 0 && <Empty description="当天没有打卡项" />}
{items.map((item) => (
<div key={item.id} className={'cal-item' + (item.completed ? ' done' : '')}>
<span className="cal-item-dot" style={{ background: item.color }} />
<span className="cal-item-name">{item.name}</span>
<span className="cal-item-meta">
{item.periodLabel} {item.target} 次 · 当日 {item.countOnDate || 0}/{item.expectedPerDay}
</span>
<span className="cal-item-right">
{item.canCheck ? (
<Button size="mini" color={item.dayDone ? 'default' : 'primary'} onClick={() => checkFromCalendar(item)}>
{item.dayDone ? '已达标' : '打卡'}
</Button>
) : (
<span className="cal-item-plan">{item.dayDone ? '计划' : '待办'}</span>
)}
</span>
<Celebrate fire={bursts[item.id] || 0} color={item.color} />
</div>
))}
</div>
</div>
)
}
+28
View File
@@ -0,0 +1,28 @@
import { useState } from 'react'
import PageHeader from '../../components/PageHeader.jsx'
import SwipeTabs from '../../components/SwipeTabs.jsx'
import MergedView from './MergedView.jsx'
import ListView from './ListView.jsx'
import { toDateStr } from './date.js'
// 打卡页: 两个视图 —— 日历(含打卡) / 清单
// V0.2.1 界面调整 (设计文档 §11.5): 原「打卡」与「日历」两个 tab 合并为一个视图,
// 上日历下打卡、按 每天/每周/每月 分组、去掉「昨天今天明天」分段.
export default function CheckinPage() {
const [active, setActive] = useState('calendar')
const [date, setDate] = useState(() => toDateStr())
const tabs = [
{ key: 'calendar', label: '打卡', render: () => <MergedView date={date} onDateChange={setDate} /> },
{ key: 'list', label: '清单', render: () => <ListView /> },
]
return (
<div className="page-with-navbar">
<PageHeader title="打卡" />
<div className="checkin-content">
<SwipeTabs tabs={tabs} active={active} onChange={setActive} />
</div>
</div>
)
}
+282
View File
@@ -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>
)
}
+528
View File
@@ -0,0 +1,528 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import {
Button, Dialog, DotLoading, Empty, Input, Popup, Segmented, Toast,
} from 'antd-mobile'
import { Celebrate } from '../../components/RingProgress.jsx'
import { checklistApi } from '../../api/checkin.js'
import useRefreshOnVisible from '../../hooks/useRefreshOnVisible.js'
// 清单视图: 进行中 / 已归档 / 模板 三类并列 (F-LS-*)
// - 进行中 / 已归档: 清单本体, 勾选即完成
// - 模板: 独立 CRUD (新建 / 编辑 / 删除), 按名称字母顺序排序 (中文按拼音)
// - 新建清单: 只填名称, 模板从右侧滑出面板中选择 (不再使用场景选择器)
const NAME_COLLATOR = new Intl.Collator('zh-Hans-CN', { numeric: true, sensitivity: 'base' })
const byName = (a, b) => NAME_COLLATOR.compare(String(a.name || a.title || ''), String(b.name || b.title || ''))
export default function ListView() {
const [tab, setTab] = useState('active') // active | archived | template
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(null)
const [showForm, setShowForm] = useState(false)
const [form, setForm] = useState({ title: '', templateId: '' })
const [pickerOpen, setPickerOpen] = useState(false) // 右侧模板选择面板
const [tplEdit, setTplEdit] = useState(null) // { id, name, items[] } | null
const [tplSaving, setTplSaving] = useState(false)
const archived = tab === 'archived'
const load = useCallback((silent) => {
if (!silent) setErr('')
checklistApi.lists(archived)
.then((d) => setLists(d.items || []))
.catch((e) => { if (!silent) setErr(e.message) })
}, [archived])
const loadTemplates = useCallback(() => {
checklistApi.templates()
.then((d) => setTemplates((d.items || []).slice().sort(byName)))
.catch(() => setTemplates([]))
}, [])
useEffect(() => { setLists(null); load(false) }, [load])
useRefreshOnVisible(() => { load(true); if (tab === 'template') loadTemplates() })
useEffect(() => { loadTemplates() }, [loadTemplates])
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 || '操作失败' })
}
}
// ---------- 清单 ----------
function openCreateForm(templateId = '') {
setForm({ title: '', templateId })
setShowForm(true)
}
async function createList() {
const title = String(form.title || '').trim()
if (!title) return Toast.show({ content: '请填写清单名称' })
const tpl = templateById(form.templateId)
try {
await checklistApi.create({
title,
// 场景不再由用户选择: 沿用模板自带的场景 (无模板则为空), 兼容历史数据展示
scene: tpl?.scene || '',
templateId: form.templateId || undefined,
})
setShowForm(false)
setForm({ title: '', templateId: '' })
setTab('active')
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) {
const items = (list.items || []).map((i) => ({ title: i.title, note: i.note }))
if (!items.length) return Toast.show({ content: '空清单无法存为模板' })
try {
await checklistApi.createTemplate({ name: list.title, scene: list.scene || '', items })
Toast.show({ content: '已保存为模板' })
loadTemplates()
} 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 || '删除失败' })
}
}
// ---------- 模板 CRUD ----------
function templateById(id) {
if (!id) return null
return (templates || []).find((t) => String(t.id) === String(id)) || null
}
function newTemplate() {
setTplEdit({ id: null, name: '', items: [{ title: '' }], builtin: false })
}
function editTemplate(tpl) {
if (tpl.builtin) return Toast.show({ content: '系统预置模板不可编辑' })
setTplEdit({
id: tpl.id,
name: tpl.name,
builtin: false,
items: (Array.isArray(tpl.items) ? tpl.items : []).map((i) => ({
title: String(i?.title || ''),
note: String(i?.note || ''),
})),
})
}
function setTplItem(idx, title) {
setTplEdit((t) => ({ ...t, items: t.items.map((it, i) => (i === idx ? { ...it, title } : it)) }))
}
function addTplItem() {
setTplEdit((t) => ({ ...t, items: [...t.items, { title: '' }] }))
}
function removeTplItem(idx) {
setTplEdit((t) => ({ ...t, items: t.items.filter((_, i) => i !== idx) }))
}
async function saveTemplate() {
if (!tplEdit) return
const name = String(tplEdit.name || '').trim()
const items = tplEdit.items
.map((i) => ({ title: String(i.title || '').trim(), note: String(i.note || '') }))
.filter((i) => i.title)
if (!name) return Toast.show({ content: '请填写模板名称' })
if (name.length > 20) return Toast.show({ content: '模板名称不能超过 20 个字' })
if (!items.length) return Toast.show({ content: '模板至少需要一个检查项' })
setTplSaving(true)
try {
if (tplEdit.id) await checklistApi.updateTemplate(tplEdit.id, { name, items })
else await checklistApi.createTemplate({ name, items })
Toast.show({ content: tplEdit.id ? '已保存' : '模板已创建' })
setTplEdit(null)
loadTemplates()
} catch (e) {
Toast.show({ content: e.message || '保存失败' })
} finally {
setTplSaving(false)
}
}
async function removeTemplate(tpl) {
if (tpl.builtin) return Toast.show({ content: '系统预置模板不可删除' })
const ok = await Dialog.confirm({
content: `删除模板「${tpl.name}」?已创建的清单不受影响。`,
confirmText: '删除',
cancelText: '取消',
})
if (!ok) return
try {
await checklistApi.removeTemplate(tpl.id)
Toast.show({ content: '模板已删除' })
loadTemplates()
} catch (e) {
Toast.show({ content: e.message || '删除失败' })
}
}
const sorted = useMemo(() => lists || [], [lists])
const templateCount = (t) => (Array.isArray(t.items) ? t.items.length : 0)
const selectedTpl = templateById(form.templateId)
return (
<div className="list-view">
<div className="list-view-head">
<Segmented
options={[
{ label: '进行中', value: 'active' },
{ label: '已归档', value: 'archived' },
{ label: '模板', value: 'template' },
]}
value={tab}
onChange={(v) => setTab(v)}
/>
{tab === 'template'
? <Button size="mini" color="primary" onClick={newTemplate}>+ 新建模板</Button>
: <Button size="mini" color="primary" onClick={() => openCreateForm()}>+ 新建</Button>}
</div>
{/* ---------- 模板分类 ---------- */}
{tab === 'template' && (
<>
{templates === null && <div className="monitor-loading"><DotLoading /> 加载中...</div>}
{templates !== null && templates.length === 0 && (
<Empty description="还没有模板">
<Button color="primary" size="small" onClick={newTemplate}>创建第一个模板</Button>
</Empty>
)}
<div className="template-wrap">
{(templates || []).map((tpl) => (
<div key={tpl.id} className="template-card">
<div className="template-head">
<span className="template-name">{tpl.name}</span>
{tpl.builtin && <span className="template-badge">预置</span>}
<span className="template-count">{templateCount(tpl)} 项</span>
</div>
<div className="template-items">
{(Array.isArray(tpl.items) ? tpl.items : []).slice(0, 4).map((it, i) => (
<span key={i} className="template-item-chip">{it.title}</span>
))}
{templateCount(tpl) > 4 && (
<span className="template-item-chip more">+{templateCount(tpl) - 4}</span>
)}
</div>
<div className="template-actions">
<span className="habit-link" onClick={() => openCreateForm(String(tpl.id))}>由此新建</span>
{!tpl.builtin && <span className="habit-link" onClick={() => editTemplate(tpl)}>编辑</span>}
{!tpl.builtin && <span className="habit-link danger" onClick={() => removeTemplate(tpl)}>删除</span>}
</div>
</div>
))}
</div>
</>
)}
{/* ---------- 清单列表 ---------- */}
{tab !== 'template' && (
<>
{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 ? '暂无归档清单' : '还没有检查清单'}>
{!archived && <Button color="primary" size="small" onClick={() => openCreateForm()}>新建清单</Button>}
</Empty>
)}
<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}
maxLength={20}
onChange={(v) => setForm((f) => ({ ...f, title: v }))}
/>
</div>
<div className="form-field">
<label>模板</label>
<div className="template-pick-row" onClick={() => setPickerOpen(true)}>
<span className={selectedTpl ? 'template-pick-name' : 'template-pick-name placeholder'}>
{selectedTpl ? selectedTpl.name : '不使用模板(空白清单)'}
</span>
<span className="template-pick-meta">
{selectedTpl ? `${templateCount(selectedTpl)} 项` : ''}
<span className="template-pick-arrow">›</span>
</span>
</div>
</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>
{/* ---------- 模板选择: 右侧滑出面板 ---------- */}
<Popup
visible={pickerOpen}
position="right"
onMaskClick={() => setPickerOpen(false)}
bodyStyle={{ width: '78vw', maxWidth: 340, height: '100%', display: 'flex', flexDirection: 'column' }}
>
<div className="tpl-picker">
<div className="tpl-picker-head">
<span className="tpl-picker-title">选择模板</span>
<span className="tpl-picker-close" onClick={() => setPickerOpen(false)}>×</span>
</div>
<div className="tpl-picker-tip">按名称字母顺序排列</div>
<div className="tpl-picker-list">
<div
className={'tpl-picker-item' + (!form.templateId ? ' active' : '')}
onClick={() => { setForm((f) => ({ ...f, templateId: '' })); setPickerOpen(false) }}
>
<span className="tpl-picker-name">不使用模板</span>
<span className="tpl-picker-sub">空白清单</span>
</div>
{(templates || []).map((tpl) => (
<div
key={tpl.id}
className={'tpl-picker-item' + (String(tpl.id) === String(form.templateId) ? ' active' : '')}
onClick={() => { setForm((f) => ({ ...f, templateId: String(tpl.id) })); setPickerOpen(false) }}
>
<span className="tpl-picker-name">
{tpl.name}
{tpl.builtin && <span className="template-badge">预置</span>}
</span>
<span className="tpl-picker-sub">{templateCount(tpl)} 项</span>
</div>
))}
{templates !== null && templates.length === 0 && (
<div className="tpl-picker-empty">还没有可用模板</div>
)}
</div>
</div>
</Popup>
{/* ---------- 模板编辑 ---------- */}
<Popup
visible={!!tplEdit}
onMaskClick={() => setTplEdit(null)}
bodyStyle={{ borderTopLeftRadius: 12, borderTopRightRadius: 12, paddingBottom: 16, maxHeight: '82vh', overflowY: 'auto' }}
>
{tplEdit && (
<div className="form-popup">
<div className="form-title">{tplEdit.id ? '编辑模板' : '新建模板'}</div>
<div className="form-field">
<label>模板名称</label>
<Input
placeholder="例如:出差 / 露营"
value={tplEdit.name}
maxLength={20}
onChange={(v) => setTplEdit((t) => ({ ...t, name: v }))}
/>
</div>
<div className="form-field">
<label>检查项({tplEdit.items.length})</label>
<div className="tpl-edit-items">
{tplEdit.items.map((it, idx) => (
<div key={idx} className="tpl-edit-item">
<span className="tpl-edit-index">{idx + 1}</span>
<Input
placeholder="检查项内容"
value={it.title}
maxLength={40}
onChange={(v) => setTplItem(idx, v)}
/>
<span className="checklist-item-del" onClick={() => removeTplItem(idx)}>×</span>
</div>
))}
<Button size="mini" fill="outline" onClick={addTplItem} style={{ marginTop: 8 }}>+ 添加检查项</Button>
</div>
</div>
<div className="form-actions">
<Button block color="primary" loading={tplSaving} onClick={saveTemplate}>保存</Button>
<Button block fill="outline" onClick={() => setTplEdit(null)} style={{ marginTop: 8 }}>取消</Button>
</div>
</div>
)}
</Popup>
</div>
)
}
+416
View File
@@ -0,0 +1,416 @@
// 打卡 · 日历与打卡合并视图 (V0.2.1 界面调整, 设计文档 §11.5)
// - 上: 月历 (月份切换 + 回到今天), 去掉「昨天/今天/明天」分段
// - 下: 当日打卡项, 按 每天 / 每周 / 每月 分组 (去掉「每年」分组)
// - 卡片紧凑化: 内边距 8px, 环形 42px/环宽 5, 间距 7px
// - 撤销 / 编辑 / 归档 收进「⋯」底部面板
// - 未来日期只读, 按钮灰显「计划」
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, addDays, addMonths, monthGrid, weekdayLabels, dayLabel, fmtMonth,
} from './date.js'
// 分组顺序: 每天 → 每周 → 每月 (去掉每年)
const GROUP_ORDER = ['day', 'week', 'month']
const GROUP_LABEL = { day: '每天', week: '每周', month: '每月' }
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 MergedView({ date, onDateChange }) {
const today = toDateStr()
const [month, setMonth] = useState(() => date.slice(0, 7))
const [cal, setCal] = useState(null) // 月历数据 { days, rate }
const [detail, setDetail] = useState(null) // 选中日详情 { today, date, items, summary, isFuture }
const [err, setErr] = useState('')
const [bursts, setBursts] = useState({})
const [sheetItem, setSheetItem] = useState(null) // 「⋯」面板对应的打卡项
const [form, setForm] = useState(EMPTY_FORM)
const [showForm, setShowForm] = useState(false)
// ---------- 数据加载 ----------
const loadCal = useCallback((silent) => {
if (!silent) setErr('')
checkinApi.calendar(month)
.then(setCal)
.catch((e) => { if (!silent) setErr(e.message) })
}, [month])
const loadDetail = useCallback((silent) => {
if (!silent) setErr('')
checkinApi.habits(date)
.then(setDetail)
.catch((e) => { if (!silent) setErr(e.message) })
}, [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 items = detail?.items || []
const isFuture = !!detail?.isFuture
// 按周期分组: 每天 → 每周 → 每月. "每年" 不在合并视图中展示, 但若存在
// 历史 year 项则归入"每月"之外的单列, 避免数据凭空消失.
const groups = useMemo(() => {
const bucket = new Map()
for (const it of items) {
const key = GROUP_ORDER.includes(it.period) ? it.period : 'other'
if (!bucket.has(key)) bucket.set(key, [])
bucket.get(key).push(it)
}
// 组内排序: 未完成在前, 已完成在后 (沿用 F-CK-16 默认口径)
for (const arr of bucket.values()) {
arr.sort((a, b) => Number(a.completed) - Number(b.completed) || a.id - b.id)
}
const order = [...GROUP_ORDER, 'other']
return order.filter((k) => bucket.has(k)).map((k) => ({
key: k,
label: GROUP_LABEL[k] || '其它周期',
items: bucket.get(k),
}))
}, [items])
const summary = detail?.summary || { total: 0, done: 0 }
const fireBurst = (id) => setBursts((m) => ({ ...m, [id]: (m[id] || 0) + 1 }))
// ---------- 打卡 / 撤销 (乐观更新) ----------
async function doCheck(item) {
// 只按"是否未来日期"拦截. 不能判 item.canCheck —— 该字段仅 /day 接口返回,
// 本视图数据来自 /habits (不含 canCheck), 用它会把所有打卡按钮全部禁用.
if (isFuture) return
const before = 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,
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,
dayDone: countOnDate >= x.expectedPerDay,
}
}),
}))
try {
const res = await checkinApi.check(item.id, date)
const fresh = await checkinApi.habits(date)
setDetail(fresh)
if (res.item?.completed) fireBurst(item.id)
loadCal(true)
} catch (e) {
setDetail(before)
Toast.show({ content: e.message || '打卡失败' })
}
}
async function doUncheck(item) {
const before = detail
setDetail((d) => ({
...d,
items: d.items.map((x) => {
if (x.id !== item.id) return x
const done = Math.max(0, x.done - 1)
const countOnDate = Math.max(0, (x.countOnDate || 0) - 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,
dayDone: countOnDate >= x.expectedPerDay,
}
}),
}))
try {
await checkinApi.uncheck(item.id, date)
const fresh = await checkinApi.habits(date)
setDetail(fresh)
loadCal(true)
} catch (e) {
setDetail(before)
Toast.show({ content: e.message || '撤销失败' })
}
}
// ---------- 新建 / 编辑 / 归档 ----------
function openCreate() {
setForm(EMPTY_FORM)
setShowForm(true)
}
function openEdit(item) {
setSheetItem(null)
setForm({ id: item.id, name: item.name, period: item.period, target: item.target, color: item.color, note: item.note })
setShowForm(true)
}
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)
loadDetail(true)
loadCal(true)
} catch (e) {
Toast.show({ content: e.message || '保存失败' })
}
}
async function doArchive(item) {
setSheetItem(null)
const ok = await Dialog.confirm({
content: `归档「${item.name}」?归档后不再出现在待办,历史记录保留。`,
confirmText: '归档',
cancelText: '取消',
})
if (!ok) return
try {
await checkinApi.deleteHabit(item.id)
Toast.show({ content: '已归档' })
loadDetail(true)
loadCal(true)
} catch (e) {
Toast.show({ content: e.message || '操作失败' })
}
}
// ---------- 渲染 ----------
return (
<div className="mv-view">
{/* 月历 */}
<div className="mv-cal">
<div className="mv-month">
<span className="arrow" onClick={() => setMonth((m) => addMonths(m, -1))}>‹</span>
<b>{fmtMonth(month)}</b>
<span className="arrow" onClick={() => setMonth((m) => addMonths(m, 1))}>›</span>
<span className="today" onClick={() => onDateChange(today)}>回到今天</span>
</div>
<div className="mv-grid mv-wd">
{weekdayLabels().map((w) => <span key={w}>{w}</span>)}
</div>
<div className="mv-grid">
{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={
'mv-day' + (inMonth ? '' : ' out') + (d === date ? ' sel' : '') +
(info?.isToday ? ' today' : '') + (info?.isFuture ? ' future' : '')
}
onClick={() => onDateChange(d)}
>
<span>{Number(d.slice(8, 10))}</span>
{inMonth && status !== 'empty' && <i className={status} />}
</div>
)
})}
</div>
{cal && (
<div className="mv-rate">
<span>本月完成率</span>
<b>{Math.round((cal.rate || 0) * 100)}%</b>
</div>
)}
</div>
{err && <div className="error-banner">{err}<span className="retry-link" onClick={() => { loadCal(false); loadDetail(false) }}>重试</span></div>}
{/* 当日栏: 右侧放「+ 新建」, 替代原来无实际作用的「点环即打卡」提示 */}
<div className="mv-daybar">
{dayLabel(date, detail?.today || today)}
{isFuture
? <span className="badge">计划(不可打卡)</span>
: <span className="badge">已完成 {summary.done} / {summary.total}</span>}
<Button
className="mv-new-btn"
size="mini"
color="primary"
onClick={openCreate}
>
+ 新建
</Button>
</div>
{detail === null && !err && <div className="monitor-loading"><DotLoading /> 加载中...</div>}
{detail !== null && items.length === 0 && (
<div className="checkin-empty">
<Empty description="当天没有打卡项" />
<Button color="primary" size="small" onClick={openCreate}>创建第一个打卡项</Button>
</div>
)}
{/* 分组列表 */}
<div className="mv-groups">
{groups.map((g) => (
<div key={g.key}>
<div className="mv-ghead">{g.label}<span className="cnt">{g.items.length} 项</span></div>
{g.items.map((item) => (
<div key={item.id} className={'mv-card' + (item.completed ? ' done' : '')}>
<RingProgress
size={42}
stroke={5}
percent={item.percent}
done={item.done}
target={item.target}
over={item.over}
completed={item.completed}
color={item.color}
onClick={() => doCheck(item)}
/>
<div className="mv-main">
<div className="mv-name">{item.name}</div>
<div className="mv-meta">
{item.periodLabel} {item.target} 次 · 当日 {item.countOnDate || 0}/{item.expectedPerDay}
{item.streak > 0 && <span className="st">连续 {item.streak}</span>}
</div>
</div>
{isFuture ? (
<div className="mv-btn grey">计划</div>
) : (
<div
className={'mv-btn' + (item.completed ? ' grey' : '')}
onClick={() => doCheck(item)}
>
{item.completed ? '再打一次' : '打卡'}
</div>
)}
<div className="mv-more" onClick={() => setSheetItem(item)}>⋯</div>
<Celebrate fire={bursts[item.id] || 0} color={item.color} />
</div>
))}
</div>
))}
</div>
{/* 「⋯」操作面板: 撤销 / 编辑 / 归档 */}
<Popup
visible={!!sheetItem}
onMaskClick={() => setSheetItem(null)}
bodyStyle={{ borderTopLeftRadius: 12, borderTopRightRadius: 12, paddingBottom: 16 }}
>
{sheetItem && (
<div className="mv-sheet">
<div className="sheet-title">{sheetItem.name}</div>
<div
className={'mv-sheet-row' + (sheetItem.done > 0 ? '' : ' disabled')}
onClick={() => { if (sheetItem.done > 0) { doUncheck(sheetItem); setSheetItem(null) } }}
>
<span>撤销上次打卡</span>
<span className="k">今日 {sheetItem.countOnDate || 0} 次</span>
</div>
<div className="mv-sheet-row" onClick={() => openEdit(sheetItem)}>
<span>编辑打卡项</span>
<span className="k">名称 / 周期 / 目标</span>
</div>
<div className="mv-sheet-row danger" onClick={() => doArchive(sheetItem)}>
<span>归档</span>
<span className="k">历史记录保留</span>
</div>
</div>
)}
</Popup>
{/* 新建 / 编辑表单 */}
<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>
)
}
+88
View File
@@ -0,0 +1,88 @@
// 前端日期/周期工具: 与 server/checkin/period.js 保持同一套口径 (Asia/Shanghai)
export const TZ = 'Asia/Shanghai'
const FMT = new Intl.DateTimeFormat('en-CA', {
timeZone: TZ, year: 'numeric', month: '2-digit', day: '2-digit',
})
/** 任意时间 -> 本地日历日 "YYYY-MM-DD" */
export function toDateStr(d = new Date()) {
return FMT.format(d instanceof Date ? d : new Date(d))
}
function toUTC(s) {
const [y, m, d] = String(s).split('-').map(Number)
return new Date(Date.UTC(y, (m || 1) - 1, d || 1))
}
function fromUTC(dt) {
return dt.toISOString().slice(0, 10)
}
export function addDays(s, n) {
const d = toUTC(s)
d.setUTCDate(d.getUTCDate() + n)
return fromUTC(d)
}
export function addMonths(s, n) {
const [y, m] = String(s).split('-').map(Number)
const first = new Date(Date.UTC(y, (m || 1) - 1 + n, 1))
const day = Number(String(s).slice(8, 10)) || 1
const lastDay = new Date(Date.UTC(first.getUTCFullYear(), first.getUTCMonth() + 1, 0)).getUTCDate()
first.setUTCDate(Math.min(day, lastDay))
return fromUTC(first)
}
/** 周一为一周第一天 */
export function startOfWeek(s) {
const dow = (toUTC(s).getUTCDay() + 6) % 7
return addDays(s, -dow)
}
export const PERIOD_LABEL = { day: '每天', week: '每周', month: '每月', year: '每年' }
export function periodRange(period, s) {
switch (period) {
case 'week': {
const start = startOfWeek(s)
return { start, end: addDays(start, 6) }
}
case 'month': {
const start = String(s).slice(0, 8) + '01'
const [y, m] = String(s).split('-').map(Number)
return { start, end: fromUTC(new Date(Date.UTC(y, m || 1, 0))) }
}
case 'year': {
const y = String(s).slice(0, 4)
return { start: y + '-01-01', end: y + '-12-31' }
}
default: return { start: s, end: s }
}
}
/** 月历网格: 返回 6*7 = 42 天 (含前后月的补位), 周一起始 */
export function monthGrid(monthStr) {
const first = monthStr + '-01'
const gridStart = startOfWeek(first)
const days = []
for (let i = 0; i < 42; i++) days.push(addDays(gridStart, i))
return days
}
export function weekdayLabels() {
return ['一', '二', '三', '四', '五', '六', '日']
}
/** 相对今天的文案: 昨天 / 今天 / 明天 / MM-DD */
export function dayLabel(dateStr, today = toDateStr()) {
if (dateStr === today) return '今天'
if (dateStr === addDays(today, -1)) return '昨天'
if (dateStr === addDays(today, 1)) return '明天'
return dateStr.slice(5).replace('-', '/')
}
export function fmtMonth(monthStr) {
const [y, m] = monthStr.split('-')
return y + '年' + Number(m) + '月'
}
+101
View File
@@ -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>
)
}
+99
View File
@@ -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>
)
}
+92 -16
View File
@@ -9,7 +9,7 @@ import {
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts'
import {
getMihomeStatus, getMihomeHistory, setMihomeAlias, syncMihome,
startMihomeAuth, getMihomeAuthStatus, cancelMihomeAuth, mihomeQrImageUrl,
startMihomeAuth, getMihomeAuthStatus, cancelMihomeAuth, fetchMihomeQrImage,
} from '../../api/mihome.js'
import useRefreshOnVisible from '../../hooks/useRefreshOnVisible.js'
@@ -21,6 +21,8 @@ const RANGE_OPTIONS = [
const round1 = (v) => (v === null || v === undefined ? null : Math.round(Number(v) * 10) / 10)
const pad = (n) => String(n).padStart(2, '0')
// 网络类失败: 不能据此断定米家授权失效
const NETWORK_ERR_RE = /fetch failed|network|ECONN|ETIMEDOUT|timeout|socket|getaddrinfo|ENOTFOUND|aborted/i
export default function EnvView() {
const [status, setStatus] = useState(null)
@@ -28,13 +30,20 @@ export default function EnvView() {
const [syncing, setSyncing] = useState(false)
const [refreshTick, setRefreshTick] = useState(0)
const [qrOpen, setQrOpen] = useState(false)
const autoPromptedRef = useRef(false)
// 依赖必须为空: 之前依赖 [status] 而成功回调又 setStatus(...), 每次响应都会换掉 load,
// 触发 effect 重新执行 -> 无限重复请求 /status. 首屏/重试用 force 强查, 定时轮询走缓存.
const load = useCallback((silent) => {
if (!silent) setErr('')
getMihomeStatus()
getMihomeStatus(!silent)
.then((d) => { setStatus(d); setErr('') })
.catch((e) => { if (!silent) { setErr(e.message); if (!status) setStatus({ auth: { status: 'expired' }, homes: [] }) } })
}, [status])
.catch((e) => {
if (silent) return
setErr(e.message)
setStatus((prev) => prev || { auth: { status: 'expired' }, homes: [] })
})
}, [])
useEffect(() => {
load(false)
@@ -43,6 +52,16 @@ export default function EnvView() {
}, [load])
useRefreshOnVisible(() => load(true))
// 会话失效时直接把扫码弹窗弹出来 (每次挂载只自动弹一次, 用户取消后不再骚扰).
// 网络抖动也会让后端判成 expired, 这类错误不自动弹, 只留横幅提示, 避免误弹.
useEffect(() => {
if (autoPromptedRef.current) return
if (status?.auth?.status !== 'expired') return
if (NETWORK_ERR_RE.test(status.auth.lastError || '')) return
autoPromptedRef.current = true
setQrOpen(true)
}, [status])
const doSync = async () => {
setSyncing(true)
try {
@@ -253,57 +272,114 @@ function EnvChart({ range, buckets }) {
function AuthPopup({ open, onClose, onOk }) {
const [state, setState] = useState(null)
const timerRef = useRef(null)
const [imgTs, setImgTs] = useState(0)
const [imgUrl, setImgUrl] = useState('')
const imgSigRef = useRef('')
const imgUrlRef = useRef('')
// 用 ref 持有最新回调: 父组件每次渲染都会新建 onClose/onOk,
// 若把它们放进 effect 依赖会导致 start() 反复重启、弹窗被反复关掉
const cbRef = useRef({ onClose, onOk })
cbRef.current = { onClose, onOk }
// 本轮是否已经结束(成功/失败), 防止多次 poll 重复触发 onOk
const settledRef = useRef(false)
const stopPoll = () => { if (timerRef.current) { clearInterval(timerRef.current); timerRef.current = null } }
const start = async () => {
stopPoll()
settledRef.current = false
setState(null)
try {
await startMihomeAuth()
} catch (e) {
settledRef.current = true
setState({ state: 'failed', lastError: e.message })
return
}
const poll = () => {
getMihomeAuthStatus()
.then((s) => {
if (settledRef.current) return
setState(s)
setImgTs(Date.now()) // 让二维码 <img> 重新拉取 (换码刷新)
if (s.state === 'failed') stopPoll()
if (s.state === 'idle' && s.running === false && s.qrImageUrl) {
if (s.state === 'failed') {
settledRef.current = true
stopPoll()
onOk()
return
}
// done 只由服务端真正登录成功置位; 且必须 running=false 才算流程走完
if (!s.running && s.done) {
settledRef.current = true
stopPoll()
cbRef.current.onOk()
}
})
.catch((e) => { stopPoll(); setState({ state: 'failed', lastError: e.message }) })
.catch((e) => {
if (settledRef.current) return
settledRef.current = true
stopPoll()
setState({ state: 'failed', lastError: e.message })
})
}
poll()
timerRef.current = setInterval(poll, 3000)
}
// 只依赖 open: start 内部通过 ref 读回调, 不会因父组件重渲染而重启
useEffect(() => {
if (open) start()
else { stopPoll(); setState(null) }
else { stopPoll(); settledRef.current = false; setState(null) }
return stopPoll
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open])
// 二维码变化时才重新拉图 (轮询 3s 一次, 用字符串签名去重, 避免每轮都请求图片)
const qrSig = state?.state === 'waiting' ? state.qrImageUrl || '' : ''
useEffect(() => {
if (!qrSig) {
imgSigRef.current = ''
setImgUrl('')
return
}
if (qrSig === imgSigRef.current) return
imgSigRef.current = qrSig
let alive = true
setImgUrl('')
fetchMihomeQrImage()
.then((url) => {
if (!alive) { URL.revokeObjectURL(url); return }
if (imgUrlRef.current) URL.revokeObjectURL(imgUrlRef.current)
imgUrlRef.current = url
setImgUrl(url)
})
.catch((e) => {
if (!alive) return
// 图片拉取失败就停轮询, 让用户点「刷新二维码」重来, 否则下一轮状态会把错误盖掉
stopPoll()
setState({ state: 'failed', lastError: e.message })
})
return () => { alive = false }
}, [qrSig])
// 卸载时释放 blob, 防止内存泄漏
useEffect(() => () => { if (imgUrlRef.current) URL.revokeObjectURL(imgUrlRef.current) }, [])
const cancel = async () => {
try { await cancelMihomeAuth() } catch { /* ignore */ }
stopPoll()
onClose()
settledRef.current = true
cbRef.current.onClose()
}
const waiting = state?.state === 'waiting' && state?.qrImageUrl
const waiting = state?.state === 'waiting' && !!imgUrl
const loading = state?.state === 'waiting' && !imgUrl
const failed = state?.state === 'failed'
return (
<Popup visible={open} onMaskClick={cancel} bodyStyle={{ minWidth: '78vw', padding: 20, borderRadius: 12 }}>
// 关闭遮罩点击: 手机端很容易误触导致弹窗一闪而过; 只允许显式点「取消」关闭
<Popup visible={open} closeOnMaskClick={false} bodyStyle={{ minWidth: '78vw', padding: 20, borderRadius: 12 }}>
<div className="env-qr">
<div className="env-qr-title">米家扫码授权</div>
{waiting ? (
<img className="env-qr-img" src={mihomeQrImageUrl() + '?ts=' + imgTs} alt="扫码授权" />
<img className="env-qr-img" src={imgUrl} alt="扫码授权" />
) : (
<div className="env-qr-wait"><DotLoading /> {failed ? '扫码流程失败' : '正在生成二维码...'}</div>
)}
@@ -311,7 +387,7 @@ function AuthPopup({ open, onClose, onOk }) {
<div className="env-qr-tip">打开米家 App → 我的 → 扫码登录,扫码后自动恢复</div>
<div className="env-qr-actions">
<Button size="mini" fill="outline" onClick={cancel}>取消</Button>
<Button size="mini" color="primary" fill="outline" loading={!waiting && !failed} onClick={() => start()}>刷新二维码</Button>
<Button size="mini" color="primary" fill="outline" loading={loading} onClick={() => start()}>刷新二维码</Button>
</div>
</div>
</Popup>
+57
View File
@@ -0,0 +1,57 @@
import TaskCard from '../../components/task/TaskCard.jsx'
import useColumnDrag from '../../components/task/useColumnDrag.js'
// 看板视图 (T-BD-01, T-UI-07)
// 拖动 = 长按 400ms 激活, 卡片原位抬起, 指针划过列高亮, 松手落列 (重点设计 §7.1)
// 不做"拖到卡片上转子任务" —— 统一走详情页「转为子任务」
export default function BoardView({
tasks, columns, onMoveColumn, onOpen, onToggleSub, onToggle, onMore, onQuickAdd,
}) {
const drag = useColumnDrag({ onDrop: onMoveColumn })
const ordered = [...(columns || [])].sort((a, b) => a.sortOrder - b.sortOrder)
return (
<div className="board-wrap">
{ordered.map((col) => {
const list = (tasks || []).filter((t) => t.columnId === col.id)
return (
<div
className={`bcol${drag.overCol === col.id ? ' dragover' : ''}`}
key={col.id}
data-column-id={col.id}
>
<div className="bcol-head">
<span className="bcol-name">{col.name}</span>
<span className="bcol-cnt">{list.length}</span>
{col.boundStatus ? (
<span className="bcol-bind">
↔ {col.boundStatus === 'done' ? '完成' : col.boundStatus === 'doing' ? '执行中' : '未开始'}
</span>
) : (
<span className="bcol-bind off">未绑定</span>
)}
</div>
<div className="bcol-body">
{list.map((t) => (
<TaskCard
key={t.id}
task={t}
dragging={drag.dragTask?.id === t.id}
bindDrag={drag.bind}
onClick={() => { if (!drag.dragTask && !drag.justEnded()) onOpen?.(t) }}
onToggleSub={onToggleSub}
onToggle={onToggle}
onMore={onMore}
/>
))}
{list.length === 0 && <div className="bcol-empty">暂无任务</div>}
<div className="badd" onClick={() => onQuickAdd?.(col.id)}>+ 添加任务</div>
</div>
</div>
)
})}
</div>
)
}
+194
View File
@@ -0,0 +1,194 @@
import { useState } from 'react'
import { Dialog, Toast } from 'antd-mobile'
import { columnApi } from '../../api/task.js'
import { RETENTION_OPTIONS, RETENTION_LABEL, STATUS_OPTIONS, STATUS_LABEL } from './date.js'
const MAX_COLUMNS = 10
// 列设置 (T-COL-*, T-BD-02/09/10, T-FL-01/02/15)
// 1) 列管理: 改名 / 新增 / 删除 / 排序
// 2) 状态绑定: 每列独立选择 不绑定 / 未开始 / 执行中 / 完成
// 3) 保留选项: 每列独立 6 档
export default function ColumnSettings({ projectId, columns, onClose, onSaved }) {
const ordered = [...(columns || [])].sort((a, b) => a.sortOrder - b.sortOrder)
const [list, setList] = useState(ordered)
const [editingName, setEditingName] = useState(null) // { id, name }
const [pending, setPending] = useState(false)
const patchLocal = (id, patch) =>
setList((prev) => prev.map((c) => (c.id === id ? { ...c, ...patch } : c)))
async function save() {
setPending(true)
try {
// 逐列保存变更(只提交与初始不同的字段)
for (const c of list) {
const orig = ordered.find((o) => o.id === c.id)
if (!orig) continue
const body = {}
if (orig.name !== c.name) body.name = c.name
if (orig.boundStatus !== c.boundStatus) body.boundStatus = c.boundStatus
if (orig.retention !== c.retention) body.retention = c.retention
if (Object.keys(body).length) await columnApi.update(c.id, body)
}
Toast.show({ content: '已保存' })
onSaved?.()
} catch (e) {
Toast.show({ content: e.message })
} finally {
setPending(false)
}
}
async function addColumn() {
if (list.length >= MAX_COLUMNS) { Toast.show({ content: `最多 ${MAX_COLUMNS} 列` }); return }
const name = `新列 ${list.length + 1}`
try {
const r = await columnApi.add(projectId, { name })
setList((prev) => [...prev, r.item])
} catch (e) {
Toast.show({ content: e.message })
}
}
async function removeColumn(col) {
if (list.length <= 1) { Toast.show({ content: '至少保留一列' }); return }
const idx = list.findIndex((c) => c.id === col.id)
const fallback = list[idx + 1] || list[idx - 1]
const ok = await Dialog.confirm({
content: `删除「${col.name}」?其中的任务将迁移到「${fallback.name}」,其状态会按目标列的绑定重新确定。`,
confirmText: '删除',
})
if (!ok) return
try {
await columnApi.remove(col.id, fallback.id)
setList((prev) => prev.filter((c) => c.id !== col.id))
Toast.show({ content: '已删除' })
} catch (e) {
Toast.show({ content: e.message })
}
}
function move(idx, dir) {
const next = [...list]
const j = idx + dir
if (j < 0 || j >= next.length) return
;[next[idx], next[j]] = [next[j], next[idx]]
setList(next)
// 立即持久化顺序
columnApi.order(projectId, next.map((c) => c.id)).catch((e) => Toast.show({ content: e.message }))
}
return (
<div className="cs-page">
<div className="navbar-sub">
<span className="back" onClick={onClose}>‹</span>
<span className="title">列设置</span>
<div className="right">
<span className="mini-btn solid" onClick={save}>{pending ? '保存中…' : '保存'}</span>
</div>
</div>
<div className="cs-body">
<div className="set-hint">
调整列顺序会影响「改状态时自动归位」的目标列(取第一个绑定该状态的列)。
</div>
<div className="section-h" style={{ margin: '12px 14px 8px' }}>列与状态绑定</div>
{list.map((col, idx) => (
<div className="set-row" key={col.id}>
<span className="cs-ord">
<span onClick={() => move(idx, -1)}>↑</span>
<span onClick={() => move(idx, 1)}>↓</span>
</span>
<span
className="sname"
onClick={() => setEditingName({ id: col.id, name: col.name })}
>
{col.name}
</span>
<div className="sctrl">
<select
className={`drop${col.boundStatus ? '' : ' off'}`}
value={col.boundStatus || ''}
onChange={(e) => patchLocal(col.id, { boundStatus: e.target.value || null })}
>
<option value="">不绑定状态</option>
{STATUS_OPTIONS.map((s) => (
<option key={s} value={s}>{STATUS_LABEL[s]}</option>
))}
</select>
<span className="cs-del" onClick={() => removeColumn(col)}>删</span>
</div>
</div>
))}
<div style={{ padding: '12px 14px' }}>
<span className="mini-btn" onClick={addColumn}>+ 新增列</span>
<span className="cs-count">当前 {list.length} / {MAX_COLUMNS} 列</span>
</div>
<div className="section-h" style={{ margin: '6px 14px 8px' }}>已完成任务保留</div>
{list.map((col) => (
<div className="set-row" key={col.id}>
<span className="sname" style={{ width: 82 }}>{col.name}</span>
<div className="sctrl">
<select
className="drop"
value={col.retention || 'forever'}
onChange={(e) => patchLocal(col.id, { retention: e.target.value })}
>
{RETENTION_OPTIONS.map((o) => (
<option key={o} value={o}>{RETENTION_LABEL[o]}</option>
))}
</select>
</div>
</div>
))}
<div className="set-hint">
保留选项:永不显示 / 当天 / 3 天 / 本周 / 本月 / 永久显示。<br />
仅影响已完成任务;未完成任务始终展示。列表与看板按同一规则过滤。
</div>
<div style={{ padding: '0 14px 20px' }}>
<div className="btn-primary" onClick={save}>{pending ? '保存中…' : '保存'}</div>
<div className="btn-ghost" onClick={onClose}>取消</div>
</div>
</div>
<Dialog
visible={!!editingName}
title="列名"
content={
<input
className="inp"
value={editingName?.name || ''}
maxLength={20}
onChange={(e) => setEditingName((p) => ({ ...p, name: e.target.value }))}
/>
}
actions={[
{ key: 'c', text: '取消', onClick: () => setEditingName(null) },
{
key: 'ok',
text: '确定',
bold: true,
onClick: () => {
const name = (editingName?.name || '').trim()
if (!name) { Toast.show({ content: '列名不能为空' }); return }
if (list.some((c) => c.id !== editingName.id && c.name === name)) {
Toast.show({ content: '列名已存在' }); return
}
patchLocal(editingName.id, { name })
setEditingName(null)
},
},
]}
onClose={() => setEditingName(null)}
/>
</div>
)
}
+78
View File
@@ -0,0 +1,78 @@
import { useState } from 'react'
import TaskRow from '../../components/task/TaskRow.jsx'
import useRowDrag from '../../components/task/useRowDrag.js'
// 列表视图 (T-VW-01, T-ST-04/05)
// 按看板列分组展示; 子任务缩进 + 折叠展开
// 换列手势与看板一致: 长按 400ms 行抬起, 左右拖动选列, 松手落列
// (列表只展示有任务的列, 空列需到看板视图拖入)
export default function ListView({
tasks, columns, onToggle, onOpen, onMore, onMoveColumn,
}) {
const [collapsed, setCollapsed] = useState({})
const drag = useRowDrag({ columns, onDrop: onMoveColumn })
if (!tasks || tasks.length === 0) {
return (
<div className="empty" style={{ paddingTop: 90 }}>
<div className="ico">✓</div>
<p>这个项目还没有任务</p>
</div>
)
}
// 分组: 按列顺序, 未匹配列的任务归到一起
const groups = []
const orderedCols = [...(columns || [])].sort((a, b) => a.sortOrder - b.sortOrder)
for (const c of orderedCols) {
const list = tasks.filter((t) => t.columnId === c.id)
if (list.length) groups.push({ key: c.id, name: c.name, list })
}
const known = new Set(orderedCols.map((c) => c.id))
const orphans = tasks.filter((t) => !known.has(t.columnId))
if (orphans.length) groups.push({ key: 'other', name: '其他', list: orphans })
return (
<div className="list-body">
{groups.map((g) => (
<div key={g.key}>
<div className="group-title">
{g.name}
<span className="cnt">{g.list.length}</span>
</div>
{g.list.map((t) => {
const kids = t.children || []
const isCollapsed = !!collapsed[t.id]
const dragging = drag.drag?.task?.id === t.id ? drag.drag : null
return (
<div key={t.id}>
<TaskRow
task={t}
onToggle={onToggle}
onClick={() => { if (!drag.justEnded()) onOpen(t) }}
onMore={onMore}
bindDrag={drag.bind}
dragTarget={dragging ? orderedCols[dragging.toIdx] : null}
/>
{kids.length > 0 && (
<>
{!isCollapsed && kids.map((c) => (
<TaskRow key={c.id} task={c} indent onToggle={onToggle} onClick={() => onOpen(c)} onMore={onMore} />
))}
<div
className="collapse"
onClick={() => setCollapsed((p) => ({ ...p, [t.id]: !p[t.id] }))}
>
{isCollapsed ? `展开 ${kids.length} 个子任务 ⌄` : `收起 ${kids.length} 个子任务 ⌃`}
</div>
</>
)}
</div>
)
})}
</div>
))}
</div>
)
}
+275
View File
@@ -0,0 +1,275 @@
import { useState } from 'react'
import { Dialog, Input, Popup, Toast } from 'antd-mobile'
import { projectApi } from '../../api/task.js'
import { useAuth } from '../../auth/AuthContext.jsx'
import useKeyboardInset from '../../hooks/useKeyboardInset.js'
import useOverlayBack from '../../hooks/useOverlayBack.js'
import ColumnSettings from './ColumnSettings.jsx'
import ProjectMembers from './ProjectMembers.jsx'
// 项目管理 (T-PJ-11/12): 成员 / 看板列 / 项目信息 三个分区的统一入口
// onExited: 项目被删除/退出/归档后, 上层应离开该项目(回落个人默认项目)
export default function ProjectManage({ project, onClose, onChanged, onExited }) {
const { user } = useAuth()
const [view, setView] = useState('main') // main | columns | members
const [renaming, setRenaming] = useState(false)
const [name, setName] = useState(project.name)
// 信息块编辑 (T-PJ-13): index=-1 新增, >=0 编辑既有块
const [noteEdit, setNoteEdit] = useState(null)
const members = project.members || []
const notes = Array.isArray(project.notes) ? project.notes : []
const isOwner = user?.id === project.ownerId
const isPersonal = !!project.isPersonal
// 返回键: 内层视图(列设置/成员)先回主区, 主区再关闭整个项目管理
useOverlayBack(true, () => { if (view !== 'main') setView('main'); else onClose() })
// 键盘弹出插入量: 信息块编辑弹层用
const kb = useKeyboardInset()
if (view === 'columns') {
return (
<ColumnSettings
projectId={project.id}
columns={project.columns || []}
onClose={() => setView('main')}
onSaved={() => { setView('main'); onChanged?.() }}
/>
)
}
if (view === 'members') {
return (
<ProjectMembers
project={project}
onClose={() => setView('main')}
onChanged={onChanged}
/>
)
}
async function saveName() {
const t = name.trim()
if (!t) { Toast.show({ content: '项目名称不能为空' }); return }
try {
await projectApi.update(project.id, { name: t })
Toast.show({ content: '已改名' })
setRenaming(false)
onChanged?.()
} catch (e) {
Toast.show({ content: e.message })
}
}
async function toggleArchive() {
const toArchive = !project.archived
const ok = await Dialog.confirm({
content: toArchive ? '归档该项目?归档后从进行中列表移出,可在归档区找回。' : '取消归档该项目?',
confirmText: toArchive ? '归档' : '取消归档',
})
if (!ok) return
try {
await projectApi.update(project.id, { archived: toArchive })
Toast.show({ content: toArchive ? '已归档' : '已恢复' })
if (toArchive) onExited?.()
else onChanged?.()
} catch (e) {
Toast.show({ content: e.message })
}
}
async function remove() {
const ok = await Dialog.confirm({
content: `删除项目「${project.name}」?其中的任务、看板列将一并删除,不可恢复。`,
confirmText: '删除',
})
if (!ok) return
try {
await projectApi.remove(project.id)
Toast.show({ content: '已删除' })
onExited?.()
} catch (e) {
Toast.show({ content: e.message })
}
}
// 信息块保存 (T-PJ-13): 整体替换 notes 数组
async function saveNote() {
const { index, title, content } = noteEdit
const next = notes.map((n) => ({ title: n.title || '', content: n.content || '' }))
const block = { title: String(title || '').trim(), content: String(content || '') }
if (!block.title && !block.content) { Toast.show({ content: '请填写标题或内容' }); return }
if (index >= 0) next[index] = block
else next.push(block)
try {
await projectApi.update(project.id, { notes: next })
Toast.show({ content: '已保存' })
setNoteEdit(null)
onChanged?.()
} catch (e) {
Toast.show({ content: e.message })
}
}
async function removeNote() {
const { index } = noteEdit
const next = notes.filter((_, i) => i !== index)
try {
await projectApi.update(project.id, { notes: next })
Toast.show({ content: '已删除' })
setNoteEdit(null)
onChanged?.()
} catch (e) {
Toast.show({ content: e.message })
}
}
async function quit() {
const ok = await Dialog.confirm({
content: '退出该项目?退出后将不再可见。',
confirmText: '退出',
})
if (!ok) return
try {
await projectApi.removeMember(project.id, user.id)
Toast.show({ content: '已退出' })
onExited?.()
} catch (e) {
Toast.show({ content: e.message })
}
}
return (
<div className="cs-page">
<div className="navbar-sub">
<span className="back" onClick={onClose}>‹</span>
<span className="title">项目管理 · {project.name}</span>
</div>
<div className="cs-body">
<div className="set-hint" style={{ paddingTop: 12 }}>
项目内所有成员权限均等、全读写;以下配置对全体成员即时生效。
</div>
<div className="section-h" style={{ margin: '6px 14px 8px' }}>成员({members.length})</div>
<div className="set-row" style={{ padding: '12px 14px' }} onClick={() => setView('members')}>
<div className="avatar">{(members[0]?.name || '?').slice(0, 1)}</div>
<span className="pn" style={{ flex: 1, fontSize: 13.5 }}>
{members.slice(0, 3).map((m) => m.name).join('、')}{members.length > 3 ? ` 等 ${members.length} 人` : ''}
</span>
<span className="link">管理 ›</span>
</div>
<div className="section-h" style={{ margin: '14px 14px 8px' }}>看板列</div>
<div className="set-row" style={{ padding: '12px 14px' }} onClick={() => setView('columns')}>
<span className="pn" style={{ flex: 1, fontSize: 13, color: '#999' }}>
{(project.columns || []).map((c) => c.name).join(' · ')}
</span>
<span className="link">列设置 ›</span>
</div>
<div className="section-h" style={{ margin: '14px 14px 8px' }}>信息块({notes.length})</div>
{notes.map((n, i) => (
<div
className="set-row note-row"
key={i}
style={{ padding: '11px 14px', alignItems: 'flex-start' }}
onClick={() => setNoteEdit({ index: i, title: n.title || '', content: n.content || '' })}
>
<div style={{ flex: 1, minWidth: 0 }}>
<div className="note-title">{n.title || '未命名'}</div>
<div className="note-preview">{String(n.content || '').split('\n')[0] || '(空)'}</div>
</div>
<span className="link" style={{ flexShrink: 0, marginTop: 2 }}>编辑</span>
</div>
))}
<div className="set-row" style={{ padding: '11px 14px' }} onClick={() => setNoteEdit({ index: -1, title: '', content: '' })}>
<span className="pn" style={{ flex: 1, fontSize: 13.5, color: 'var(--color-primary)' }}>+ 添加信息块</span>
</div>
<div className="section-h" style={{ margin: '14px 14px 8px' }}>项目信息</div>
<div className="set-row" style={{ padding: '12px 14px' }} onClick={() => { setName(project.name); setRenaming(true) }}>
<span className="pn" style={{ flex: 1, fontSize: 13.5 }}>项目名称</span>
<span style={{ fontSize: 12.5, color: '#999' }}>{project.name}</span>
</div>
<div className="set-row" style={{ padding: '12px 14px' }} onClick={toggleArchive}>
<span className="pn" style={{ flex: 1, fontSize: 13.5 }}>归档项目</span>
<span className="link">{project.archived ? '取消归档' : '归档'}</span>
</div>
{!isPersonal && (
<div className="set-row" style={{ padding: '12px 14px' }} onClick={remove}>
<span className="pn" style={{ flex: 1, fontSize: 13.5, color: '#ff3141' }}>删除项目</span>
<span className="link danger">删除</span>
</div>
)}
{isOwner && !isPersonal && (
<div className="set-row" style={{ padding: '12px 14px' }} onClick={quit}>
<span className="pn" style={{ flex: 1, fontSize: 13.5, color: '#ff3141' }}>退出项目</span>
<span className="link danger">退出</span>
</div>
)}
<div className="set-hint">
个人默认项目不可删除、不可退出(T-PJ-02);退出项目仅创建人可见(T-PJ-07)。
</div>
</div>
<Dialog
visible={renaming}
title="项目名称"
content={
<div style={{ marginTop: 8 }}>
<Input placeholder="项目名称" value={name} onChange={setName} maxLength={20} />
</div>
}
actions={[
{ key: 'c', text: '取消', onClick: () => setRenaming(false) },
{ key: 'ok', text: '保存', bold: true, onClick: saveName },
]}
onClose={() => setRenaming(false)}
/>
{/* 信息块编辑 (T-PJ-13): 标题 + 长文本, 独立编辑 */}
<Popup
visible={!!noteEdit}
onMaskClick={() => setNoteEdit(null)}
position="bottom"
bodyStyle={{
borderTopLeftRadius: 12,
borderTopRightRadius: 12,
maxHeight: '86vh',
overflowY: 'auto',
padding: `16px 16px calc(env(safe-area-inset-bottom, 0px) + 20px + ${kb}px)`,
}}
>
<div className="sheet-title">{noteEdit?.index >= 0 ? '编辑信息块' : '添加信息块'}</div>
<div className="sheet-sub">用于记录 Readme 等项目相关长文本,每块独立编辑</div>
<div className="field">
<label>标题</label>
<Input
placeholder="如:项目说明 / Readme"
value={noteEdit?.title || ''}
maxLength={50}
onChange={(v) => setNoteEdit((p) => ({ ...(p || {}), title: v }))}
/>
</div>
<div className="field">
<label>内容</label>
<textarea
className="inp"
rows={10}
placeholder="支持多行长文本…"
value={noteEdit?.content || ''}
onChange={(e) => setNoteEdit((p) => ({ ...(p || {}), content: e.target.value }))}
/>
</div>
<div className="btn-primary" onClick={saveNote}>保存</div>
{noteEdit?.index >= 0 && (
<div className="btn-danger" onClick={removeNote}>删除此信息块</div>
)}
<div className="btn-ghost" onClick={() => setNoteEdit(null)}>取消</div>
</Popup>
</div>
)
}
+111
View File
@@ -0,0 +1,111 @@
import { useState } from 'react'
import { Dialog, Toast } from 'antd-mobile'
import { projectApi } from '../../api/task.js'
import { useAuth } from '../../auth/AuthContext.jsx'
// 项目成员 (T-PJ-06/07, T-AS-06, T-PM-02)
export default function ProjectMembers({ project, onClose, onChanged }) {
const { user } = useAuth()
const meId = user?.id
const [members, setMembers] = useState(project.members || [])
const [adding, setAdding] = useState(false)
const [email, setEmail] = useState('')
async function add() {
const e = email.trim()
if (!e) return
try {
await projectApi.addMember(project.id, { email: e })
setEmail('')
setAdding(false)
Toast.show({ content: '已添加' })
onChanged?.()
onClose?.()
} catch (err) {
Toast.show({ content: err.message })
}
}
async function remove(m, isSelf) {
const ok = await Dialog.confirm({
content: isSelf ? '退出该项目?退出后将不再可见。' : `移除成员「${m.name}」?`,
confirmText: isSelf ? '退出' : '移除',
})
if (!ok) return
try {
await projectApi.removeMember(project.id, m.userId)
setMembers((prev) => prev.filter((x) => x.userId !== m.userId))
Toast.show({ content: isSelf ? '已退出' : '已移除' })
onChanged?.()
if (isSelf) onClose?.()
} catch (err) {
Toast.show({ content: err.message })
}
}
return (
<div className="cs-page">
<div className="navbar-sub">
<span className="back" onClick={onClose}>‹</span>
<span className="title">{project.name}</span>
<div className="right">
{!project.isPersonal && (
<span className="mini-btn solid" onClick={() => setAdding(true)}>+ 添加</span>
)}
</div>
</div>
<div className="cs-body">
<div className="set-hint">
{project.isPersonal
? '个人默认项目不可添加成员。'
: '项目内所有成员权限均等、全读写;负责人与成员只是信息标记,不影响权限。'}
</div>
{members.map((m) => {
const isOwner = m.userId === project.ownerId
const isMe = m.userId === meId
return (
<div className="set-row" key={m.userId} style={{ padding: '12px 14px' }}>
<div className="avatar">{(m.name || '?').slice(0, 1)}</div>
<span className="pn" style={{ flex: 1, fontSize: 13.5 }}>
{m.name}
{isMe && <span className="tag grey" style={{ marginLeft: 6 }}>我</span>}
{isOwner && <span className="tag grey" style={{ marginLeft: 6 }}>创建人</span>}
</span>
{isMe && !project.isPersonal && (
<span className="link danger" onClick={() => remove(m, true)}>退出</span>
)}
{!isMe && isOwner && <span className="link" style={{ color: '#999' }}>—</span>}
{!isMe && !isOwner && !project.isPersonal && (
<span className="link danger" onClick={() => remove(m, false)}>移除</span>
)}
</div>
)
})}
<div className="set-hint">
成员被移出项目后,其担任的负责人 / 成员标记会保留历史并显示为「已退出」,不清空、不报错。
</div>
</div>
<Dialog
visible={adding}
title="添加成员"
content={
<input
className="inp"
placeholder="输入成员邮箱"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
}
actions={[
{ key: 'c', text: '取消', onClick: () => setAdding(false) },
{ key: 'ok', text: '添加', bold: true, onClick: add },
]}
onClose={() => setAdding(false)}
/>
</div>
)
}
+271
View File
@@ -0,0 +1,271 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { ActionSheet, Dialog, DotLoading, Toast } from 'antd-mobile'
import { projectApi, taskApi } from '../../api/task.js'
import { filterVisible, msToNextBoundary } from './date.js'
import { getViewMode, setViewMode } from './prefs.js'
import ListView from './ListView.jsx'
import BoardView from './BoardView.jsx'
import TaskDetail from './TaskDetail.jsx'
import ProjectManage from './ProjectManage.jsx'
import CopyTaskPopup from '../../components/task/CopyTaskPopup.jsx'
// 项目视图容器: 列表 / 看板 切换 + 数据装载 + 乐观更新
// 嵌在任务首屏下部 (T-VW-09); 头部仅保留「项目管理」与「+ 新增」(T-PJ-11)
export default function ProjectView({ projectId, onProjectChanged, onProjectExited }) {
const [project, setProject] = useState(null)
const [tasks, setTasks] = useState(null)
const [columns, setColumns] = useState([])
const [err, setErr] = useState('')
const [mode, setMode] = useState(() => getViewMode(projectId) || 'list')
const [openTask, setOpenTask] = useState(null)
const [showManage, setShowManage] = useState(false)
const [adding, setAdding] = useState(false)
const [tick, setTick] = useState(0) // 保留窗口重算触发
const [copyTask, setCopyTask] = useState(null) // ⋯菜单触发的跨项目拷贝
const load = useCallback(() => {
setErr('')
Promise.all([projectApi.detail(projectId), taskApi.list(projectId)])
.then(([p, t]) => {
setProject(p.item)
setColumns(p.item.columns || [])
setTasks(t.items || [])
})
.catch((e) => setErr(e.message))
}, [projectId])
useEffect(() => { setTasks(null); load() }, [load])
// 切换视图并记忆 (T-VW-02/03/06)
const switchMode = (next) => {
setMode(next)
setViewMode(projectId, next)
}
// 保留窗口到点重算 (T-EX-18)
useEffect(() => {
const options = (columns || []).map((c) => c.retention)
const near = options
.map((o) => msToNextBoundary(o))
.filter((v) => v != null)
.sort((a, b) => a - b)[0]
const t = setTimeout(() => setTick((v) => v + 1), near ? Math.min(near, 60_000) : 60_000)
return () => clearTimeout(t)
}, [columns, tick])
const visibleTasks = useMemo(
() => filterVisible(tasks || [], columns),
// eslint-disable-next-line react-hooks/exhaustive-deps
[tasks, columns, tick],
)
// ---- 乐观更新 ----
const patchTask = (id, patch) => setTasks((prev) => (prev || []).map((t) => {
if (t.id === id) return { ...t, ...patch }
const kids = (t.children || []).map((c) => (c.id === id ? { ...c, ...patch } : c))
return { ...t, children: kids }
}))
const replaceTask = (fresh) => setTasks((prev) => (prev || []).map((t) => (t.id === fresh.id ? fresh : t)))
/** 更新任务(列/状态/字段), 服务端裁决后以返回值为准 */
async function updateTask(id, patch) {
const snapshot = tasks
patchTask(id, patch)
try {
const r = await taskApi.update(id, patch)
replaceTask(r.item)
return r.item
} catch (e) {
setTasks(snapshot)
Toast.show({ content: e.message })
throw e
}
}
/** 勾选完成 / 取消完成 */
const toggleDone = (task) => updateTask(task.id, {
status: task.status === 'done' ? 'todo' : 'done',
})
/** 拖拽换列 (T-BD-04/07) */
const moveToColumn = async (task, columnId) => {
const col = columns.find((c) => c.id === columnId)
try {
await updateTask(task.id, { columnId })
const target = columns.find((c) => c.id === columnId)
if (target?.boundStatus && target.boundStatus !== task.status) {
Toast.show({ content: `已移入 ${col?.name},状态改为「${target.boundStatus === 'done' ? '完成' : target.boundStatus === 'doing' ? '执行中' : '未开始'}」` })
} else {
Toast.show({ content: `已移入 ${col?.name}` })
}
} catch { /* 已回滚并提示 */ }
}
/** 改状态 (T-BD-14~16) */
const changeStatus = async (task, status) => {
try {
const fresh = await updateTask(task.id, { status })
const moved = fresh && fresh.columnId !== task.columnId
if (moved) {
const col = columns.find((c) => c.id === fresh.columnId)
Toast.show({ content: `已移至 ${col?.name || '目标列'}` })
}
} catch { /* ignore */ }
}
/** 新建任务 */
async function createTask(title, parentId = null) {
const t = String(title || '').trim()
if (!t) return
try {
await taskApi.create(projectId, { title: t, parentId })
setAdding(false)
load()
} catch (e) {
Toast.show({ content: e.message })
}
}
/** 任务 ⋯ 菜单: 编辑 / 拷贝到其他项目 / 删除 */
function openTaskMenu(task) {
ActionSheet.show({
actions: [
{ text: '编辑', key: 'edit' },
{ text: '拷贝到其他项目', key: 'copy' },
{ text: '删除', key: 'delete', bold: true, danger: true },
],
cancelText: '取消',
onAction: async (item) => {
if (item.key === 'edit') setOpenTask(task)
else if (item.key === 'copy') setCopyTask(task)
else if (item.key === 'delete') {
const kids = task.children?.length || 0
const ok = await Dialog.confirm({
content: kids
? `删除「${task.title}」?将一并删除 ${kids} 条子任务。`
: `删除「${task.title}」?`,
confirmText: '删除',
})
if (!ok) return
try {
await taskApi.remove(task.id)
Toast.show({ content: '已删除' })
load()
} catch (e) {
Toast.show({ content: e.message })
}
}
},
})
}
if (err) {
return (
<div className="pv-embed">
<div className="empty" style={{ paddingTop: 40 }}>
<div className="ico err">!</div>
<p>{err}</p>
<div className="mini-btn solid" onClick={load}>重试</div>
</div>
</div>
)
}
if (!project || !tasks) {
return (
<div className="pv-embed">
<div className="empty" style={{ padding: '40px 20px' }}>
<div className="ico">··</div><p>加载中…</p><DotLoading color="#1677ff" />
</div>
</div>
)
}
return (
<div className="pv-embed">
<div className="navbar-sub task-projbar">
<span className="title">{project.name}</span>
<div className="right">
<span className="mini-btn" onClick={() => setShowManage(true)}>项目管理</span>
<span className="mini-btn solid" onClick={() => setAdding((v) => !v)}>+ 新增</span>
</div>
</div>
<div className={`task-content${mode === 'board' ? ' is-board' : ''}`}>
<div className="pv-head">
<div className="seg">
<div className={mode === 'list' ? 'on' : ''} onClick={() => switchMode('list')}>列表</div>
<div className={mode === 'board' ? 'on' : ''} onClick={() => switchMode('board')}>看板</div>
</div>
</div>
{adding && (
<div className="quick-add">
<input
className="inp"
autoFocus
placeholder="输入任务标题,回车创建"
onKeyDown={(e) => {
if (e.key === 'Enter') createTask(e.currentTarget.value)
if (e.key === 'Escape') setAdding(false)
}}
/>
</div>
)}
{mode === 'list' ? (
<ListView
tasks={visibleTasks}
columns={columns}
onToggle={toggleDone}
onOpen={(t) => setOpenTask(t)}
onMore={openTaskMenu}
onMoveColumn={moveToColumn}
/>
) : (
<BoardView
tasks={visibleTasks}
columns={columns}
onMoveColumn={moveToColumn}
onOpen={(t) => setOpenTask(t)}
onToggleSub={(c) => toggleDone(c)}
onToggle={(t) => toggleDone(t)}
onMore={openTaskMenu}
onQuickAdd={() => { setAdding(true) }}
/>
)}
</div>
{openTask && (
<TaskDetail
task={openTask}
columns={columns}
members={project.members || []}
tasks={tasks || []}
onClose={() => setOpenTask(null)}
onSaved={() => { setOpenTask(null); load() }}
onChanged={load}
/>
)}
{copyTask && (
<CopyTaskPopup
task={copyTask}
visible
onClose={() => setCopyTask(null)}
onDone={load}
/>
)}
{showManage && (
<ProjectManage
project={project}
onClose={() => setShowManage(false)}
onChanged={() => { load(); onProjectChanged?.() }}
onExited={() => { setShowManage(false); onProjectExited?.() }}
/>
)}
</div>
)
}
+334
View File
@@ -0,0 +1,334 @@
import { useEffect, useState } from 'react'
import { ActionSheet, Dialog, Popup, Toast } from 'antd-mobile'
import { taskApi } from '../../api/task.js'
import { STATUS_OPTIONS, STATUS_LABEL } from './date.js'
import UserPicker from '../../components/task/UserPicker.jsx'
import StatusDot from '../../components/task/StatusDot.jsx'
import CopyTaskPopup from '../../components/task/CopyTaskPopup.jsx'
import useKeyboardInset from '../../hooks/useKeyboardInset.js'
import useOverlayBack from '../../hooks/useOverlayBack.js'
// 任务详情 (T-TK-*, T-ST-03, T-AS-05)
// - 状态: 三段式分段控件, 当前项高亮, 点按即切换
// - 看板列: ActionSheet 选择, 列/状态联动由服务端裁决 (T-BD-*)
// - 转为子任务 / 转为顶层任务 (T-ST-12): 从项目内顶层任务中选父任务
export default function TaskDetail({ task, columns, members, tasks, onClose, onSaved, onChanged }) {
const [form, setForm] = useState(() => ({
title: task.title || '',
description: task.description || '',
memberIds: (task.members || []).map((m) => m.userId),
assigneeId: task.assignee?.id ?? null,
status: task.status,
tags: task.tags || [],
}))
const [subs, setSubs] = useState(task.children || [])
const [newSub, setNewSub] = useState('')
const [saving, setSaving] = useState(false)
const [picker, setPicker] = useState(null) // 'assignee' | 'members'
const [copyOpen, setCopyOpen] = useState(false)
const [parentPick, setParentPick] = useState(false) // 转为子任务: 选择父任务
// 键盘弹出插入量: 抬升弹层内容, 输入框不被遮挡
const kb = useKeyboardInset()
// 返回键: 先关详情弹层, 不离开任务页
useOverlayBack(true, onClose)
const assigneeName = members.find((m) => m.userId === form.assigneeId)?.name
const memberNames = members.filter((m) => form.memberIds.includes(m.userId)).map((m) => m.name)
const currentCol = (columns || []).find((c) => c.id === task.columnId)
const isSubtask = task.parentId != null
// 可作为父任务的候选: 同项目顶层任务, 排除自己与已有子任务的任务 (T-ST-01/13)
const parentCandidates = (tasks || []).filter(
(t) => t.id !== task.id && (t.children || []).length === 0,
)
useEffect(() => {
setForm({
title: task.title || '',
description: task.description || '',
memberIds: (task.members || []).map((m) => m.userId),
assigneeId: task.assignee?.id ?? null,
status: task.status,
tags: task.tags || [],
})
setSubs(task.children || [])
}, [task])
async function save() {
if (!form.title.trim()) { Toast.show({ content: '标题不能为空' }); return }
setSaving(true)
try {
const r = await taskApi.update(task.id, {
title: form.title.trim(),
description: form.description,
assigneeId: form.assigneeId,
memberIds: form.memberIds,
status: form.status,
tags: form.tags,
})
Toast.show({ content: '已保存' })
onSaved?.(r.item)
} catch (e) {
Toast.show({ content: e.message })
} finally {
setSaving(false)
}
}
/** 换列: 服务端按目标列绑定裁决状态 (T-BD-04/07) */
function pickColumn() {
ActionSheet.show({
actions: (columns || []).map((c) => ({
text: c.name + (c.id === task.columnId ? ' ✓' : ''),
key: String(c.id),
})),
cancelText: '取消',
onAction: async (item) => {
const columnId = Number(item.key)
if (columnId === task.columnId) return
try {
const r = await taskApi.update(task.id, { columnId })
Toast.show({ content: `已移入 ${(columns || []).find((c) => c.id === columnId)?.name || '目标列'}` })
onSaved?.(r.item)
} catch (e) {
Toast.show({ content: e.message })
}
},
})
}
/** 转为子任务 (T-ST-12) / 转为顶层 */
async function changeParent(parentId) {
try {
const r = await taskApi.update(task.id, { parentId })
Toast.show({ content: parentId ? '已转为子任务' : '已转为顶层任务' })
onSaved?.(r.item)
} catch (e) {
Toast.show({ content: e.message })
}
}
async function addSub() {
const t = newSub.trim()
if (!t) return
try {
const r = await taskApi.create(task.projectId, { title: t, parentId: task.id })
setSubs((prev) => [...prev, r.item])
setNewSub('')
onChanged?.()
} catch (e) {
Toast.show({ content: e.message })
}
}
async function toggleSub(sub) {
const next = sub.status === 'done' ? 'todo' : 'done'
try {
const r = await taskApi.update(sub.id, { status: next })
setSubs((prev) => prev.map((s) => (s.id === sub.id ? r.item : s)))
onChanged?.()
} catch (e) {
Toast.show({ content: e.message })
}
}
async function removeTask() {
const ok = await Dialog.confirm({
content: subs.length
? `删除该任务?将一并删除 ${subs.length} 条子任务。`
: '删除该任务?',
confirmText: '删除',
})
if (!ok) return
try {
await taskApi.remove(task.id)
Toast.show({ content: '已删除' })
onSaved?.()
} catch (e) {
Toast.show({ content: e.message })
}
}
return (
<Popup
visible
onMaskClick={onClose}
position="bottom"
bodyStyle={{
borderTopLeftRadius: 12,
borderTopRightRadius: 12,
maxHeight: `calc(88vh - ${kb}px)`,
overflowY: 'auto',
padding: `16px 16px calc(env(safe-area-inset-bottom, 0px) + 20px + ${kb}px)`,
}}
>
<div className="sheet-title">{task.title}</div>
<div className="sheet-sub">
{currentCol?.name} · {STATUS_LABEL[task.status]}
</div>
<div className="field">
<label>标题</label>
<input
className="inp"
value={form.title}
maxLength={200}
onChange={(e) => setForm((p) => ({ ...p, title: e.target.value }))}
/>
</div>
<div className="field">
<label>描述</label>
<textarea
className="inp"
rows={2}
placeholder="补充说明…"
value={form.description}
onChange={(e) => setForm((p) => ({ ...p, description: e.target.value }))}
/>
</div>
{/* 状态: 三段式, 当前项高亮, 点按即切换 */}
<div className="field">
<label>状态</label>
<div className="seg">
{STATUS_OPTIONS.map((s) => (
<div
key={s}
className={form.status === s ? 'on' : ''}
onClick={() => setForm((p) => ({ ...p, status: s }))}
>
{STATUS_LABEL[s]}
</div>
))}
</div>
</div>
{/* 看板列: 点击换列, 状态随目标列联动 */}
<div className="field">
<label>看板列</label>
<div className="pick-row" onClick={pickColumn}>
<span className="pn">{currentCol?.name || '未分组'}</span>
<span className="link">更换 ›</span>
</div>
</div>
{/* 层级: 转为子任务 / 转为顶层 (T-ST-12) */}
<div className="field">
<label>层级</label>
{isSubtask ? (
<div className="pick-row" onClick={() => changeParent(null)}>
<span className="pn">子任务 · 可转为顶层任务</span>
<span className="link">转为顶层</span>
</div>
) : (
<div className="pick-row" onClick={() => setParentPick(true)}>
<span className="pn">顶层任务 · 可挂到其他任务下</span>
<span className="link">转为子任务 ›</span>
</div>
)}
</div>
<div className="field">
<label>负责人</label>
<div className="pick-row" onClick={() => setPicker('assignee')}>
{assigneeName ? (
<div className="avatar">{assigneeName.slice(0, 1)}</div>
) : (
<div className="avatar" style={{ background: '#f2f3f5', color: '#b6bcc4' }}>—</div>
)}
<span className="pn">{assigneeName || '未指派'}</span>
<span className="link">更换</span>
</div>
</div>
<div className="field">
<label>任务成员</label>
<div className="pick-row" onClick={() => setPicker('members')}>
<div className="avatar" style={{ background: '#eef2f7', color: '#7b8794' }}>
{form.memberIds.length || 0}
</div>
<span className="pn" style={{ fontSize: 12.5, color: form.memberIds.length ? 'inherit' : '#999' }}>
{memberNames.length ? memberNames.join('、') : '未选择成员'}
</span>
<span className="link">编辑</span>
</div>
</div>
<div className="section-h">子任务 <span className="tag">{subs.length}</span></div>
{subs.map((s) => (
<div className="pick-row" key={s.id} onClick={() => toggleSub(s)}>
<span className={`ck${s.status === 'done' ? ' on' : ''}`} />
<span className="pn">{s.title}</span>
<StatusDot status={s.status} />
</div>
))}
<div className="extra-row" style={{ marginTop: 8 }}>
<input
className="inp"
placeholder="添加子任务,回车确认"
value={newSub}
maxLength={200}
onChange={(e) => setNewSub(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') addSub() }}
onFocus={(e) => {
setTimeout(() => e.target.scrollIntoView({ block: 'center', behavior: 'smooth' }), 300)
}}
/>
</div>
<div className="btn-primary" onClick={save}>{saving ? '保存中…' : '保存'}</div>
<div className="btn-ghost" onClick={() => setCopyOpen(true)}>拷贝到其他项目</div>
<div className="btn-danger" onClick={removeTask}>
删除任务{subs.length > 0 && <span className="del-hint">(将一并删除 {subs.length} 条子任务)</span>}
</div>
<UserPicker
visible={picker === 'assignee'}
title="选择负责人"
members={members}
value={form.assigneeId}
onClose={() => setPicker(null)}
onConfirm={(uid) => setForm((p) => ({ ...p, assigneeId: uid }))}
/>
<UserPicker
visible={picker === 'members'}
title="选择任务成员"
multiple
members={members}
value={form.memberIds}
onClose={() => setPicker(null)}
onConfirm={(ids) => setForm((p) => ({ ...p, memberIds: Array.isArray(ids) ? ids : [] }))}
/>
<CopyTaskPopup
task={task}
visible={copyOpen}
onClose={() => setCopyOpen(false)}
onDone={onChanged}
/>
{/* 转为子任务: 从项目内顶层任务中选父任务 */}
<Popup
visible={parentPick}
onMaskClick={() => setParentPick(false)}
bodyStyle={{
borderTopLeftRadius: 12, borderTopRightRadius: 12,
maxHeight: '70vh', overflowY: 'auto',
padding: '16px 16px calc(env(safe-area-inset-bottom, 0px) + 20px)',
}}
>
<div className="sheet-title">转为子任务</div>
<div className="sheet-sub">选择父任务(仅支持一级;已有子任务的任务不能作为父任务)</div>
{parentCandidates.length === 0 && <div className="extras-empty">没有可选的父任务</div>}
{parentCandidates.map((t) => (
<div className="pick-row" key={t.id} onClick={() => { setParentPick(false); changeParent(t.id) }}>
<span className="pn">{t.title}</span>
<span className="link">选定</span>
</div>
))}
<div className="btn-ghost" onClick={() => setParentPick(false)}>取消</div>
</Popup>
</Popup>
)
}
+347
View File
@@ -0,0 +1,347 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useLocation, useNavigate, useParams } from 'react-router-dom'
import { Dialog, DotLoading, Input, Toast } from 'antd-mobile'
import PageHeader from '../../components/PageHeader.jsx'
import { projectApi } from '../../api/task.js'
import {
getLastProjectId, setLastProjectId, setRecentIds, prunePrefs,
getRecentIds, getRecentOrder, setRecentOrder,
} from './prefs.js'
import RecentProjects from '../../components/task/RecentProjects.jsx'
import ProjectView from './ProjectView.jsx'
// 任务模块容器 (T-PM-06, T-VW-08/09)
// /task 首屏: 上 = 最近项目条(3 个 + 更多入口), 下 = 当前项目的任务管理
// /task/list 完整项目列表(从「更多项目」进入; 支持拖动排序 + 新建)
// /task/:pid 打开指定项目(深链), 布局同首屏
export default function TaskPage() {
const { pid } = useParams()
const { pathname } = useLocation()
const navigate = useNavigate()
const isList = pathname === '/task/list'
const [projects, setProjects] = useState(null)
const [err, setErr] = useState('')
const [currentId, setCurrentId] = useState(null)
const [createSignal, setCreateSignal] = useState(0) // 通知项目列表弹起新建对话框
const load = useCallback(() => {
setErr('')
projectApi.list()
.then((d) => {
const items = d.items || []
setProjects(items)
prunePrefs(items.map((p) => p.id))
})
.catch((e) => setErr(e.message))
}, [])
useEffect(() => { load() }, [load])
// 决定当前项目: 深链 pid > 上次访问 > 个人默认项目 (T-VW-09)
useEffect(() => {
if (!projects) return
const ok = (x) => x && projects.some((p) => p.id === x && !p.archived)
let id = pid ? Number(pid) : getLastProjectId()
if (!ok(id)) id = projects.find((p) => p.isPersonal)?.id ?? projects[0]?.id ?? null
if (!ok(id)) id = null
setCurrentId(id)
if (id) { setLastProjectId(id); setRecentIds(id) }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [projects, pid])
const selectProject = (id) => {
setCurrentId(id)
setLastProjectId(id)
setRecentIds(id)
if (pid) navigate('/task', { replace: true })
}
// 项目被删除 / 退出 / 归档后: 重载并回落个人默认项目
const handleExited = () => {
setCurrentId(null)
load()
}
if (isList) {
return (
<div className="page-with-navbar">
<div className="navbar-sub task-projbar">
<span className="back" onClick={() => navigate('/task')}>‹</span>
<span className="title">我的项目</span>
<div className="right">
<span className="mini-btn solid" onClick={() => setCreateSignal((v) => v + 1)}>+ 新建</span>
</div>
</div>
<div className="task-content">
<ProjectList
projects={projects}
err={err}
onRetry={load}
onOpen={(id) => { selectProject(id); navigate('/task') }}
onChanged={load}
createSignal={createSignal}
onCreateConsumed={() => setCreateSignal(0)}
/>
</div>
</div>
)
}
return (
<div className="page-with-navbar">
<PageHeader title="任务" />
<div className="task-home">
{err && !projects ? (
<div className="empty">
<div className="ico err">!</div>
<p>{err}</p>
<div className="mini-btn solid" onClick={load}>重试</div>
</div>
) : !projects ? (
<div className="empty" style={{ padding: '40px 20px' }}>
<div className="ico">··</div>
<p>加载中…</p>
<DotLoading color="#1677ff" />
</div>
) : (
<>
<RecentProjects
projects={projects}
currentId={currentId}
onSelect={selectProject}
onMore={() => navigate('/task/list')}
/>
{currentId != null && (
<ProjectView
key={currentId}
projectId={currentId}
onProjectChanged={load}
onProjectExited={handleExited}
/>
)}
</>
)}
</div>
</div>
)
}
const COLORS = ['#1677ff', '#00b578', '#ff8f1f', '#a855f7', '#14b8a6', '#f43f9e']
const colorOf = (p) => COLORS[Math.abs(p.id) % COLORS.length]
const LONG_PRESS = 300
const MOVE_TOLERANCE = 8
// 完整项目列表 (从「更多项目」进入)
// 顺序: 手动排序(recentOrder, 拖动结果)优先 > 最近访问 > 列表序; 已归档固定在底部
function ProjectList({ projects, err, onRetry, onOpen, onChanged, createSignal, onCreateConsumed }) {
const [creating, setCreating] = useState(false)
const [name, setName] = useState('')
const [saving, setSaving] = useState(false)
const [ver, setVer] = useState(0) // 拖动排序后强制重算
const [dragId, setDragId] = useState(null) // 拖拽中的项目 id (视觉态)
const listRef = useRef(null)
const timer = useRef(null)
const startRef = useRef(null)
const activeRef = useRef(false)
const dragIdRef = useRef(null)
// 头部「+ 新建」→ 弹起新建对话框 (一次性: 消费后清零, 否则下次进入该页会再次弹出)
useEffect(() => {
if (createSignal) {
setCreating(true)
onCreateConsumed?.()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [createSignal])
const active = useMemo(() => {
const manual = getRecentOrder()
const score = new Map(manual.map((id, i) => [id, i]))
let n = score.size
getRecentIds().forEach((id) => { if (!score.has(id)) score.set(id, n++) })
const fb = n + 1000
return projects
.filter((p) => !p.archived)
.slice()
.sort((a, b) => (score.get(a.id) ?? fb) - (score.get(b.id) ?? fb))
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [projects, ver])
if (err) {
return (
<div className="empty">
<div className="ico err">!</div>
<p>{err}</p>
<div className="mini-btn solid" onClick={onRetry}>重试</div>
</div>
)
}
if (!projects) {
return <div className="empty"><div className="ico">··</div><p>加载中…</p><DotLoading color="#1677ff" /></div>
}
const archived = projects.filter((p) => p.archived)
async function create() {
const t = name.trim()
if (!t) { Toast.show({ content: '请输入项目名称' }); return }
setSaving(true)
try {
const r = await projectApi.create({ name: t })
setName('')
setCreating(false)
Toast.show({ content: '已创建' })
onChanged?.()
onOpen?.(r.item.id)
} catch (e) {
Toast.show({ content: e.message })
} finally {
setSaving(false)
}
}
// ---- 长按拖动排序 (纵向): 复用首屏卡片同一套 prefs.recentOrder ----
function cleanup() {
if (timer.current) { clearTimeout(timer.current); timer.current = null }
activeRef.current = false
startRef.current = null
dragIdRef.current = null
document.removeEventListener('touchmove', onMove, { passive: false })
document.removeEventListener('touchend', onEnd)
document.removeEventListener('mousemove', onMove)
document.removeEventListener('mouseup', onEnd)
}
const pointOf = (ev) => {
const t = ev.touches?.[0] || ev.changedTouches?.[0] || ev
return { x: t.clientX, y: t.clientY }
}
function indexAt(y) {
const items = Array.from(listRef.current?.querySelectorAll('[data-pid]') || [])
for (let i = 0; i < items.length; i++) {
const r = items[i].getBoundingClientRect()
if (y < r.top + r.height / 2) return i
}
return Math.max(0, items.length - 1)
}
function onMove(ev) {
const p = pointOf(ev)
if (!activeRef.current) {
if (startRef.current && (Math.abs(p.x - startRef.current.x) > MOVE_TOLERANCE || Math.abs(p.y - startRef.current.y) > MOVE_TOLERANCE)) {
if (timer.current) { clearTimeout(timer.current); timer.current = null }
}
return
}
if (ev.cancelable) ev.preventDefault()
const ids = active.map((x) => x.id)
const from = ids.indexOf(dragIdRef.current)
if (from < 0) return
const to = indexAt(p.y)
if (to !== from) {
const [moved] = ids.splice(from, 1)
ids.splice(to, 0, moved)
setRecentOrder(ids)
setVer((v) => v + 1)
}
}
function onEnd() {
cleanup()
setDragId(null)
}
function beginPress(p, ev) {
if (ev.button && ev.button !== 0) return
const pt = pointOf(ev)
startRef.current = pt
activeRef.current = false
cleanup()
dragIdRef.current = p.id
timer.current = setTimeout(() => {
activeRef.current = true
setDragId(p.id)
if (navigator.vibrate) navigator.vibrate(10)
}, LONG_PRESS)
document.addEventListener('touchmove', onMove, { passive: false })
document.addEventListener('touchend', onEnd)
document.addEventListener('mousemove', onMove)
document.addEventListener('mouseup', onEnd)
}
useEffect(() => () => cleanup(), [])
return (
<div ref={listRef}>
<div className="rp-drag-tip" style={{ padding: '8px 14px 6px' }}>
长按项目可拖动排序(本机记忆,与首屏最近项目一致)
</div>
{active.map((p) => (
<div
className={`proj-item${p.id === dragId ? ' dragging' : ''}`}
key={p.id}
data-pid={p.id}
onTouchStart={(ev) => beginPress(p, ev)}
onMouseDown={(ev) => beginPress(p, ev)}
onClick={() => { if (!activeRef.current && !dragId) onOpen(p.id) }}
>
<div className="pico" style={{ background: colorOf(p) }}>
{(p.name || '?').slice(0, 1)}
</div>
<div className="pmain">
<div className="ptitle">
{p.name}
{p.isPersonal && <span className="badge">个人</span>}
</div>
<div className="psub">
{p.taskCount ?? 0} 个任务 · {p.memberCount ?? 1} 人
</div>
</div>
<div className="pcount">⠿</div>
</div>
))}
{active.length === 0 && (
<div className="empty" style={{ paddingTop: 90 }}>
<div className="ico">▤</div>
<p>还没有协作项目</p>
<div className="mini-btn solid" onClick={() => setCreating(true)}>新建项目</div>
</div>
)}
{archived.length > 0 && (
<>
<div className="proj-group">已归档({archived.length})</div>
{archived.map((p) => (
<div className="proj-item" key={p.id} style={{ opacity: 0.65 }} onClick={() => onOpen(p.id)}>
<div className="pico" style={{ background: '#a8b0ba' }}>{(p.name || '?').slice(0, 1)}</div>
<div className="pmain">
<div className="ptitle">{p.name}</div>
<div className="psub">已归档 · {p.taskCount ?? 0} 个任务</div>
</div>
<div className="pcount">›</div>
</div>
))}
</>
)}
<div style={{ height: 24 }} />
<Dialog
visible={creating}
title="新建项目"
content={
<div className="field" style={{ marginTop: 8 }}>
<Input placeholder="项目名称" value={name} onChange={setName} maxLength={20} />
</div>
}
actions={[
{ key: 'cancel', text: '取消', onClick: () => setCreating(false) },
{ key: 'ok', text: saving ? '创建中…' : '创建', bold: true, onClick: create },
]}
onClose={() => setCreating(false)}
/>
</div>
)
}
+97
View File
@@ -0,0 +1,97 @@
// 任务模块前端: 自然日边界 + 保留选项可见性
// 与后端 server/task-domain/date.js 保持同一口径, 改动需同步
export const TZ = 'Asia/Shanghai'
export const RETENTION_LABEL = {
never: '永不显示',
today: '当天',
'3d': '3 天',
week: '本周',
month: '本月',
forever: '永久显示',
}
export const RETENTION_OPTIONS = ['never', 'today', '3d', 'week', 'month', 'forever']
export const STATUS_LABEL = { todo: '未开始', doing: '执行中', done: '完成' }
export const STATUS_OPTIONS = ['todo', 'doing', 'done']
const FMT = new Intl.DateTimeFormat('en-CA', {
timeZone: TZ, year: 'numeric', month: '2-digit', day: '2-digit',
})
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 startOfWeek(s) {
const dow = (toUTC(s).getUTCDay() + 6) % 7
return addDays(s, -dow)
}
export function startOfMonth(s) {
return String(s).slice(0, 8) + '01'
}
/** 保留选项 -> 窗口起点 ("YYYY-MM-DD") 或 特殊标记 */
export function retentionStart(option, now = new Date()) {
const today = toDateStr(now)
switch (option) {
case 'never': return { mode: 'never' }
case 'forever': return { mode: 'all' }
case 'today': return { mode: 'since', date: today }
case '3d': return { mode: 'since', date: addDays(today, -2) }
case 'week': return { mode: 'since', date: startOfWeek(today) }
case 'month': return { mode: 'since', date: startOfMonth(today) }
default: return { mode: 'all' }
}
}
/** 单条任务在某保留选项下是否可见 */
export function isVisibleByRetention(task, option, now = new Date()) {
if (task.status !== 'done') return true
const win = retentionStart(option, now)
if (win.mode === 'never') return false
if (win.mode === 'all') return true
if (!task.doneAt) return false
return toDateStr(task.doneAt) >= win.date
}
/** 按任务所属列的保留选项过滤任务 */
export function filterVisible(tasks, columns, now = new Date()) {
const map = new Map((columns || []).map((c) => [c.id, c.retention || 'forever']))
return (tasks || []).filter((t) => isVisibleByRetention(t, map.get(t.columnId) || 'forever', now))
}
/** 距离下一个自然日/周/月边界的毫秒数, 用于定时重算 (T-EX-18) */
export function msToNextBoundary(option, now = new Date()) {
if (!['today', '3d', 'week', 'month'].includes(option)) return null
const today = toDateStr(now)
let next
if (option === 'today' || option === '3d') next = addDays(today, 1)
else if (option === 'week') next = addDays(startOfWeek(today), 7)
else {
const [y, m] = today.split('-').map(Number)
const nm = new Date(Date.UTC(y, m, 1))
next = nm.toISOString().slice(0, 10)
}
// 本地零点转北京时间零点: 用 Date 构造 "next 00:00 +08:00"
const target = new Date(next + 'T00:00:00+08:00').getTime()
return Math.max(1000, target - now.getTime())
}
+97
View File
@@ -0,0 +1,97 @@
// 任务模块: 设备端偏好 (不落库, 不跨设备)
// lastProjectId 上次访问的项目 (T-VW-07)
// viewMode 按项目记忆的视图 list | board (T-VW-03/06)
// recentIds 自动按最近访问排序的项目 id (最前 = 最近), 上限 10 (T-VW-08)
// recentOrder 首屏最近项目卡片的手动排序结果, 优先于 recentIds (T-VW-10)
const KEY = 'iboard_task_prefs'
function blank() {
return { lastProjectId: null, viewMode: {}, recentIds: [], recentOrder: [] }
}
function read() {
try {
const raw = localStorage.getItem(KEY)
if (!raw) return blank()
const obj = JSON.parse(raw)
return {
lastProjectId: obj.lastProjectId ?? null,
viewMode: obj.viewMode && typeof obj.viewMode === 'object' ? obj.viewMode : {},
recentIds: Array.isArray(obj.recentIds) ? obj.recentIds : [],
recentOrder: Array.isArray(obj.recentOrder) ? obj.recentOrder : [],
}
} catch {
return blank()
}
}
function write(obj) {
try {
localStorage.setItem(KEY, JSON.stringify(obj))
} catch { /* 忽略配额错误 */ }
}
export function getLastProjectId() {
return read().lastProjectId
}
export function setLastProjectId(id) {
const cur = read()
cur.lastProjectId = id ?? null
write(cur)
}
/** 视图记忆: 无记录返回 null, 由调用方决定默认值 */
export function getViewMode(projectId) {
if (!projectId) return null
return read().viewMode[String(projectId)] || null
}
export function setViewMode(projectId, mode) {
if (!projectId) return
const cur = read()
cur.viewMode[String(projectId)] = mode
write(cur)
}
/** 最近访问记录: 把 id 提到队首, 上限 10 (T-VW-08) */
export function getRecentIds() {
return read().recentIds
}
export function setRecentIds(id) {
if (!id) return
const cur = read()
cur.recentIds = [id, ...cur.recentIds.filter((x) => x !== id)].slice(0, 10)
write(cur)
}
/** 首屏卡片手动排序 (T-VW-10): 完整顺序, 排在前面的项目优先展示 */
export function getRecentOrder() {
return read().recentOrder
}
export function setRecentOrder(ids) {
const cur = read()
cur.recentOrder = (Array.isArray(ids) ? ids : []).map(Number).filter(Boolean)
write(cur)
}
/** 清理已不可见的项目记录 */
export function prunePrefs(visibleIds) {
const set = new Set((visibleIds || []).map(Number))
const cur = read()
let dirty = false
if (cur.lastProjectId && !set.has(cur.lastProjectId)) { cur.lastProjectId = null; dirty = true }
for (const k of Object.keys(cur.viewMode)) {
if (!set.has(Number(k))) { delete cur.viewMode[k]; dirty = true }
}
const ri = cur.recentIds.filter((id) => set.has(id))
const ro = cur.recentOrder.filter((id) => set.has(id))
if (ri.length !== cur.recentIds.length || ro.length !== cur.recentOrder.length) dirty = true
if (dirty) {
cur.recentIds = ri
cur.recentOrder = ro
write(cur)
}
}
+694 -11
View File
@@ -10,7 +10,11 @@
--color-primary: #1677ff;
--color-accent: #1677ff;
--color-danger: #ff3141;
--safe-top: env(safe-area-inset-top, 0px);
/* 顶部状态栏让位高度.
Android WebView (Tauri) 下 env(safe-area-inset-top) 常常返回 0, 仅靠它会让内容
顶到状态栏底下 (V0.2 移除了 45px 的 NavBar 后暴露). 这里给一个保守兜底值,
再由 main.jsx 的 calibrateSafeTop() 用实测高度覆盖 --safe-top, 双保险. */
--safe-top: max(env(safe-area-inset-top, 0px), 0px);
--safe-bottom: env(safe-area-inset-bottom, 0px);
--safe-left: env(safe-area-inset-left, 0px);
--safe-right: env(safe-area-inset-right, 0px);
@@ -49,6 +53,16 @@ input, textarea, select { font: inherit; }
padding-right: var(--safe-right);
}
/* 顶部状态栏安全区占位: 高度由 --safe-top 决定 (main.jsx 实测校准后覆盖)
默认页面底色; 有通栏彩色头部的页面 (如「我」页蓝色渐变) 由该页
给 body 加标记类来覆盖背景, 使安全区与头部无缝衔接. */
.app-safe-top {
flex: 0 0 auto;
height: var(--safe-top);
width: 100%;
background: var(--color-bg);
}
.app-main {
flex: 1 1 auto;
min-height: 0;
@@ -58,6 +72,11 @@ input, textarea, select { font: inherit; }
-webkit-overflow-scrolling: touch;
}
/* 页面容器: 高度打通 app-main → page-with-navbar 的百分比链。
没有这行 height:100%, page-with-navbar 的 height:100% 对 auto 高度的父级失效,
整页塌成内容高 —— 看板 board-wrap 随之只卡片区那么高, 横滑只在有任务的区域生效 */
.page-transition { height: 100%; }
/* 页面切换动画: pathname 变化时整页淡入 + 轻微上移 (由 MobileShell 控制 class) */
.page-transition.active {
animation: page-in 0.28s ease;
@@ -67,12 +86,31 @@ input, textarea, select { font: inherit; }
to { opacity: 1; transform: translateY(0); }
}
/* 带有 antd-mobile NavBar 的页面 (Home/Monitor/Macro/Article) */
/* 带有页面头部的页面 (Checkin/Monitor/Macro) */
/* height:100% 让页面占满 app-main 高度, 内部 tab-content 独立滚动,
头部 NavBar 与分类 tab 固定在顶部不上滚 */
头部与分类 tab 固定在顶部不上滚 */
.page-with-navbar { display: flex; flex-direction: column; height: 100%; min-height: 0; }
.page-with-navbar .adm-error-block { flex: 1; }
/* 统一主 Tab 头部 (打卡 / 宏观 / 监控): 原 NavBar 45px → 23px, 标题主色小字
注意: 「我」页不使用此头部, 见 MePage (顶部直接是头像区) */
.page-header {
height: 23px;
flex: 0 0 23px;
display: flex;
align-items: center;
justify-content: center;
background: var(--color-bg);
overflow: hidden;
}
.page-header-title {
font-size: 12px;
line-height: 1;
font-weight: 600;
letter-spacing: 1px;
color: var(--color-primary);
}
/* 底部 antd-mobile TabBar 定位 */
.app-tabbar.adm-tab-bar {
position: fixed;
@@ -92,11 +130,53 @@ input, textarea, select { font: inherit; }
/* 宏观页 SwipeTabs */
.swipe-tabs-wrap {
position: relative;
display: flex;
flex-direction: column;
flex: 1 1 auto;
min-height: 0;
}
/* tab 条右侧溢出提示: 渐变遮罩 + 箭头, 表示右边还有未展示的 tab
遮罩宽度留足, 让被截断的 tab 文字淡出而不是被箭头压住 */
.swipe-tabs-wrap .adm-tabs-header {
position: relative;
background: var(--color-card);
}
.swipe-tabs-wrap .adm-tabs-tab-wrapper { padding: 0 10px; }
.tab-more-hint {
position: absolute;
top: 0;
right: 0;
height: 45px;
width: 58px;
z-index: 3;
display: flex;
align-items: center;
justify-content: flex-end;
padding-right: 6px;
cursor: pointer;
background: linear-gradient(to right, rgba(255,255,255,0) 0%, #fff 62%, #fff 100%);
}
.tab-more-arrow {
display: inline-flex;
align-items: center;
justify-content: center;
width: 19px;
height: 19px;
border-radius: 50%;
background: #fff;
border: 1px solid #e3e6ea;
color: var(--color-primary);
font-size: 14px;
line-height: 1;
font-weight: 700;
box-shadow: 0 1px 4px rgba(0,0,0,.14);
animation: tab-more-nudge 1.5s ease-in-out infinite;
}
@keyframes tab-more-nudge {
0%, 100% { transform: translateX(0); }
50% { transform: translateX(3px); }
}
.tab-content {
flex: 1 1 auto;
min-height: 0;
@@ -200,9 +280,18 @@ input, textarea, select { font: inherit; }
.login-error { background: #fef2f2; color: var(--color-danger); padding: 10px; border-radius: 6px; font-size: 13px; margin-bottom: 12px; }
/* ================== 我的页 ================== */
/* 「我」页不设顶部头部条: 头像区直接贴顶 (2026-10-23 界面调整)
该页头部是通栏蓝色渐变, 若安全区单独占位会出现一条白边/色块把蓝色割断.
做法: 让安全区高度归零, 改由 me-profile 用 padding-top 吸收状态栏高度,
这样蓝色渐变从屏幕最顶端开始, 是一整块连续的色域. */
body.me-safe-top .app-safe-top { height: 0; }
.me-page { display: flex; flex-direction: column; min-height: 100%; background: var(--color-bg); }
.me-navbar.adm-nav-bar { background: #fff; border-bottom: 1px solid var(--color-border); }
.me-profile { background: linear-gradient(135deg, #1677ff, #4096ff); color: #fff; padding: 24px 16px 28px; display: flex; flex-direction: column; align-items: center; gap: 6px; }
.me-page .me-profile {
background: linear-gradient(135deg, #1677ff, #4096ff);
color: #fff;
padding: calc(24px + var(--safe-top)) 16px 28px;
display: flex; flex-direction: column; align-items: center; gap: 6px;
}
.me-avatar { background: rgba(255,255,255,.2); color: #fff; font-size: 28px; }
.me-name { font-size: 18px; font-weight: 600; }
.me-email { font-size: 13px; opacity: .85; }
@@ -210,12 +299,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 +586,600 @@ 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; }
/* ----- 模板分类 (清单的第三个分段: 进行中 / 已归档 / 模板) ----- */
.template-wrap { display: flex; flex-direction: column; gap: 10px; }
.template-card {
background: #fff; border-radius: 10px; padding: 12px;
box-shadow: 0 1px 2px rgba(0,0,0,.04);
}
.template-head { display: flex; align-items: center; gap: 8px; }
.template-name { font-size: 15px; font-weight: 600; color: var(--color-text); word-break: break-all; }
.template-badge {
font-size: 10px; color: var(--color-muted); background: #f2f3f5;
border-radius: 4px; padding: 1px 5px; flex-shrink: 0;
}
.template-count { margin-left: auto; font-size: 11px; color: var(--color-muted); flex-shrink: 0; }
.template-items { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
.template-item-chip {
font-size: 11px; color: var(--color-muted); background: #f7f8fa;
border-radius: 4px; padding: 2px 7px; max-width: 100%;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.template-item-chip.more { color: var(--color-primary); background: #f0f5ff; }
.template-actions {
display: flex; flex-wrap: wrap; gap: 14px; margin-top: 10px;
padding-top: 8px; border-top: 1px dashed var(--color-border);
}
/* 新建清单: 模板行 (点击后从右侧滑出选择面板) */
.template-pick-row {
display: flex; align-items: center; gap: 8px; cursor: pointer;
border: 1px solid var(--color-border); border-radius: 8px; padding: 8px 10px;
}
.template-pick-row:active { background: #fafafa; }
.template-pick-name { flex: 1; font-size: 14px; color: var(--color-text); }
.template-pick-name.placeholder { color: #b8bcc4; }
.template-pick-meta { display: inline-flex; align-items: center; gap: 4px; font-size: 12px; color: var(--color-muted); }
.template-pick-arrow { font-size: 16px; line-height: 1; color: #c8c8c8; }
/* 模板选择面板 (右侧滑出) */
.tpl-picker { display: flex; flex-direction: column; height: 100%; padding: 14px 0 0; }
.tpl-picker-head { display: flex; align-items: center; padding: 0 16px 6px; }
.tpl-picker-title { font-size: 16px; font-weight: 700; color: var(--color-text); }
.tpl-picker-close { margin-left: auto; font-size: 22px; line-height: 1; color: #c8c8c8; cursor: pointer; padding: 0 4px; }
.tpl-picker-tip { font-size: 11px; color: var(--color-muted); padding: 0 16px 8px; }
.tpl-picker-list { flex: 1 1 auto; min-height: 0; overflow-y: auto; padding: 0 10px 16px; }
.tpl-picker-item {
display: flex; align-items: baseline; gap: 8px; padding: 11px 10px;
border-radius: 8px; cursor: pointer;
}
.tpl-picker-item:active { background: #f5f6f8; }
.tpl-picker-item.active { background: #f0f5ff; }
.tpl-picker-name {
flex: 1; font-size: 14px; font-weight: 600; color: var(--color-text);
display: inline-flex; align-items: center; gap: 6px; word-break: break-all;
}
.tpl-picker-item.active .tpl-picker-name { color: var(--color-primary); }
.tpl-picker-sub { font-size: 11px; color: var(--color-muted); flex-shrink: 0; }
.tpl-picker-empty { padding: 40px 0; text-align: center; font-size: 13px; color: var(--color-muted); }
/* 模板编辑弹层 */
.tpl-edit-items { display: flex; flex-direction: column; gap: 6px; }
.tpl-edit-item { display: flex; align-items: center; gap: 8px; }
.tpl-edit-index { width: 18px; text-align: right; font-size: 11px; color: var(--color-muted); flex-shrink: 0; }
.tpl-edit-item .adm-input {
flex: 1; border: 1px solid var(--color-border); border-radius: 6px; padding: 6px 8px;
}
/* ----- 表单弹窗 (打卡项 / 清单) ----- */
.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); }
/* ================== 打卡 · 日历与打卡合并视图 (V0.2.1, 设计文档 §11.5) ================== */
/* 上: 月历; 下: 当日打卡项, 按 每天/每周/每月 分组. 卡片紧凑化. */
.mv-view { display: flex; flex-direction: column; min-height: 0; }
/* ---- 月历 ---- */
.mv-cal { background: #fff; border-radius: 10px; margin: 8px 12px 0; padding: 6px 6px 8px; box-shadow: 0 1px 2px rgba(0,0,0,.04); }
.mv-month { display: flex; align-items: center; gap: 8px; padding: 2px 4px 6px; }
.mv-month b { font-size: 14px; }
.mv-month .arrow { font-size: 17px; color: var(--color-muted); line-height: 1; padding: 0 3px; cursor: pointer; }
.mv-month .today { margin-left: auto; font-size: 11px; color: var(--color-primary); cursor: pointer; }
.mv-grid { display: grid; grid-template-columns: repeat(7, 1fr); }
.mv-wd span { text-align: center; font-size: 10px; color: var(--color-muted); padding: 1px 0 3px; display: block; }
.mv-day {
height: 31px; margin: 1px 0; display: flex; flex-direction: column;
align-items: center; justify-content: center; border-radius: 6px;
font-size: 12px; line-height: 1.15; color: var(--color-text); cursor: pointer;
}
.mv-day.out { color: #c8c8c8; }
.mv-day.future { color: #b0b0b0; }
.mv-day.today { color: var(--color-primary); font-weight: 700; }
.mv-day.sel { background: var(--color-primary); color: #fff; font-weight: 600; }
.mv-day i { width: 4px; height: 4px; border-radius: 50%; margin-top: 2px; }
.mv-day i.partial { background: #ff8f1f; }
.mv-day i.full { background: #00b578; }
.mv-day i.over { background: #ff6b6b; }
.mv-day.sel i { background: #fff; }
.mv-rate { display: flex; justify-content: space-between; align-items: center; font-size: 11px; color: var(--color-muted); padding: 7px 6px 0; }
.mv-rate b { color: var(--color-primary); }
/* ---- 当日栏 ---- */
.mv-daybar { display: flex; align-items: center; gap: 8px; padding: 10px 14px 2px; font-size: 13px; font-weight: 600; }
.mv-daybar .badge { font-weight: 400; color: var(--color-muted); font-size: 11px; }
/* 「+ 新建」按钮右对齐 (原「点环即打卡」文字位置) */
.mv-daybar .mv-new-btn { margin-left: auto; flex-shrink: 0; font-size: 11px; }
/* ---- 分组 ---- */
.mv-groups { padding-bottom: 4px; }
.mv-ghead { display: flex; align-items: center; gap: 6px; padding: 9px 14px 5px; font-size: 11px; font-weight: 600; color: var(--color-muted); }
.mv-ghead::before { content: ""; width: 3px; height: 11px; border-radius: 2px; background: var(--color-primary); }
.mv-ghead .cnt { margin-left: auto; font-weight: 400; color: #b8bcc4; }
/* ---- 紧凑卡片 ---- */
.mv-card {
position: relative; display: flex; align-items: center; gap: 9px; background: #fff;
border-radius: 9px; padding: 8px 10px; margin: 0 12px 7px; box-shadow: 0 1px 2px rgba(0,0,0,.04);
}
.mv-card.done { background: #f7fbf8; }
.mv-main { flex: 1; min-width: 0; }
.mv-name { font-size: 14px; font-weight: 600; line-height: 1.25; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.mv-meta { font-size: 10px; color: var(--color-muted); margin-top: 2px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.mv-meta .st { color: #ff8f1f; background: rgba(255,143,31,.12); border-radius: 3px; padding: 0 4px; margin-left: 5px; }
.mv-btn {
font-size: 11px; border-radius: 999px; padding: 4px 12px; background: var(--color-primary);
color: #fff; flex-shrink: 0; white-space: nowrap; cursor: pointer;
}
.mv-btn.grey { background: #f2f3f5; color: var(--color-muted); }
.mv-more { color: #c0c4cc; font-size: 15px; line-height: 1; padding: 4px 2px 4px 6px; flex-shrink: 0; cursor: pointer; }
/* ---- 「⋯」操作面板 ---- */
.mv-sheet { padding: 16px 16px 4px; }
.mv-sheet .sheet-title { font-size: 15px; font-weight: 600; margin-bottom: 8px; }
.mv-sheet-row {
display: flex; align-items: center; justify-content: space-between;
padding: 12px 4px; border-top: 1px solid var(--color-border); font-size: 14px; cursor: pointer;
}
.mv-sheet-row.danger { color: var(--color-danger); }
.mv-sheet-row.disabled { opacity: .45; cursor: default; }
.mv-sheet-row .k { font-size: 11px; color: #b8bcc4; }
/* ================== 任务模块 (V0.2, 需求 T-*) ================== */
/* 项目列表 / 列表视图 / 看板视图 / 列设置 / 任务详情 / 成员 */
.task-content { flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; overflow-y: auto; }
.task-content.is-board { overflow: hidden; }
/* ---- 项目列表 ---- */
.proj-head { display: flex; align-items: center; padding: 12px 14px 8px; }
.proj-head b { font-size: 16px; }
.proj-head .right { margin-left: auto; display: flex; gap: 6px; }
.proj-group { font-size: 11px; color: #b8bcc4; letter-spacing: .5px; padding: 10px 16px 6px; }
.proj-item {
display: flex; align-items: center; gap: 12px; background: #fff; border-radius: 10px;
padding: 13px 14px; margin: 0 12px 8px; box-shadow: 0 1px 2px rgba(0,0,0,.04); cursor: pointer;
}
.proj-item .pico {
width: 34px; height: 34px; border-radius: 9px; display: flex; align-items: center;
justify-content: center; color: #fff; font-size: 14px; font-weight: 700; flex-shrink: 0;
}
.proj-item .pmain { flex: 1; min-width: 0; }
.proj-item .ptitle { font-size: 14.5px; font-weight: 600; display: flex; align-items: center; gap: 6px; }
.proj-item .psub { font-size: 11.5px; color: var(--color-muted); margin-top: 2px; }
.proj-item .pcount { font-size: 15px; color: #c0c4cc; flex-shrink: 0; }
.badge { font-size: 10px; color: #ad6800; background: #fff7e6; border-radius: 4px; padding: 0 5px; font-weight: 400; }
/* ---- 视图头部 / 快速新增 ---- */
.pv-head { padding: 10px 14px 8px; display: flex; align-items: center; gap: 10px; flex-shrink: 0; }
.pv-head .seg { display: flex; background: #f2f3f5; border-radius: 8px; padding: 3px; width: 112px; }
.pv-head .seg div { flex: 1; text-align: center; font-size: 13px; padding: 5px 0; border-radius: 6px; color: var(--color-muted); cursor: pointer; }
.pv-head .seg div.on { background: #fff; color: var(--color-text); font-weight: 600; box-shadow: 0 1px 2px rgba(0,0,0,.06); }
.pv-head .right { margin-left: auto; display: flex; gap: 6px; }
.mini-btn {
font-size: 11px; border: 1px solid var(--color-primary); color: var(--color-primary);
border-radius: 999px; padding: 3px 10px; background: #fff; white-space: nowrap; cursor: pointer;
}
.mini-btn.solid { background: var(--color-primary); color: #fff; }
.mini-btn.grey { border-color: #dcdcdc; color: var(--color-muted); }
.quick-add { padding: 0 12px 8px; flex-shrink: 0; }
/* ---- 列表视图 ---- */
.list-body { flex: 1 1 auto; min-height: 0; overflow-y: auto; padding-bottom: 16px; }
.group-title { display: flex; align-items: center; gap: 7px; padding: 12px 16px 6px; font-size: 12px; color: var(--color-muted); font-weight: 600; }
.group-title::before { content: ""; width: 3px; height: 12px; border-radius: 2px; background: var(--color-primary); }
.group-title .cnt { color: #b8bcc4; font-weight: 400; }
.task-row {
display: flex; align-items: flex-start; gap: 9px; background: #fff; border-radius: 9px;
padding: 10px 12px; margin: 0 12px 7px; box-shadow: 0 1px 2px rgba(0,0,0,.04); cursor: pointer;
}
.task-row.done { background: #fafbfa; }
.task-row .box, .sub-row .box {
width: 19px; height: 19px; border-radius: 50%; border: 1.5px solid #d0d0d0;
display: flex; align-items: center; justify-content: center; flex-shrink: 0; margin-top: 1px; cursor: pointer;
}
.task-row.done .box, .sub-row.done .box { background: #00b578; border-color: #00b578; }
.box svg { opacity: 0; }
.task-row.done .box svg, .sub-row.done .box svg { opacity: 1; }
.task-main { flex: 1; min-width: 0; }
.task-title { font-size: 14px; display: flex; align-items: center; gap: 6px; word-break: break-word; }
.task-row.done .task-title { color: #a0a0a0; text-decoration: line-through; }
.task-meta { display: flex; align-items: center; gap: 7px; margin-top: 4px; font-size: 11px; color: #999; flex-wrap: wrap; }
.task-meta .col { color: var(--color-primary); background: #f0f5ff; border-radius: 4px; padding: 0 5px; }
.task-meta .assignee { font-size: 12px; font-weight: 600; color: var(--color-text); }
.task-meta .assignee.none { color: #b8bcc4; font-weight: 400; }
.task-meta .members { font-size: 10.5px; color: #a7adb6; }
.task-right { display: flex; align-items: center; flex-shrink: 0; }
.more { color: #c2c7cf; font-size: 16px; line-height: 1; padding: 0 2px; cursor: pointer; }
.sub-row {
display: flex; align-items: center; gap: 8px; background: #fff; border-radius: 8px;
padding: 8px 12px 8px 30px; margin: -2px 12px 7px 26px; box-shadow: 0 1px 2px rgba(0,0,0,.03);
position: relative; cursor: pointer;
}
.sub-row::before { content: ""; position: absolute; left: 14px; top: 0; bottom: 0; width: 1px; background: #e9ecf0; }
.sub-row .box { width: 16px; height: 16px; }
.sub-title { flex: 1; font-size: 13px; color: #444; }
.sub-row.done .sub-title { color: #a8a8a8; text-decoration: line-through; }
.collapse { font-size: 11px; color: var(--color-primary); padding: 2px 0 8px 30px; margin: 0 12px; cursor: pointer; }
.sub-add-hint { font-size: 11px; color: #b8bcc4; padding: 0 0 8px 44px; cursor: pointer; }
/* ---- 状态点 ---- */
.sdot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; flex-shrink: 0; }
.sdot.todo { background: #c9ced6; }
.sdot.doing { background: #1677ff; }
.sdot.done { background: #00b578; }
.sdot.pill { width: auto; height: auto; border-radius: 999px; font-size: 10px; padding: 0 6px; line-height: 15px; color: #fff; }
.sdot.pill.todo { background: #a8b0ba; }
.sdot.pill.doing { background: #1677ff; }
.sdot.pill.done { background: #00b578; }
/* ---- 看板视图 ---- */
.board-wrap {
display: flex; gap: 10px; padding: 0 12px 8px; overflow-x: auto; overflow-y: hidden;
flex: 1 1 auto; min-height: 0; align-items: stretch;
}
.bcol { flex: 0 0 186px; display: flex; flex-direction: column; min-height: 0; }
.bcol-head { display: flex; align-items: center; gap: 5px; padding: 0 2px 7px; flex-shrink: 0; }
.bcol-name { font-size: 12px; font-weight: 700; color: #3d4450; }
.bcol-cnt { font-size: 11px; color: #999; background: #e8ebef; border-radius: 999px; padding: 0 6px; line-height: 15px; }
.bcol-bind { font-size: 10px; color: var(--color-primary); background: #f0f5ff; border-radius: 4px; padding: 0 4px; margin-left: auto; }
.bcol-bind.off { color: #999; background: #f2f3f5; }
.bcol-body { flex: 1 1 auto; min-height: 0; overflow-y: auto; display: flex; flex-direction: column; gap: 7px; padding-bottom: 8px; }
.bcol.dragover .bcol-body { background: #eaf2ff; border-radius: 8px; outline: 1.5px dashed var(--color-primary); }
.bcol-empty { font-size: 11px; color: #c0c4cc; text-align: center; padding: 10px 0; }
.bcard {
background: #fff; border-radius: 8px; padding: 9px 10px;
box-shadow: 0 1px 2px rgba(0,0,0,.05); cursor: pointer; flex-shrink: 0;
}
.bcard.hl { outline: 2px solid var(--color-primary); outline-offset: 1px; }
.brow1 { display: flex; align-items: flex-start; gap: 6px; }
.btitle { flex: 1; font-size: 12.5px; font-weight: 600; line-height: 1.45; word-break: break-word; }
.bstat { display: flex; align-items: center; padding: 2px; cursor: pointer; }
.bmeta { display: flex; align-items: center; gap: 6px; margin-top: 6px; font-size: 10.5px; }
.bassignee { font-size: 11px; font-weight: 600; color: #4a5160; }
.bassignee.none { color: #b8bcc4; font-weight: 400; }
.bmembers { font-size: 10px; color: #a7adb6; }
.bsub {
display: flex; align-items: center; gap: 5px; margin-top: 5px; padding-top: 5px;
border-top: 1px dashed #eef0f3; font-size: 11px; color: #5a616b; cursor: pointer;
}
.bsub .sbox { width: 13px; height: 13px; border-radius: 50%; border: 1.3px solid #d0d0d0; flex-shrink: 0; }
.bsub.done .sbox { background: #00b578; border-color: #00b578; }
.bsub .stitle { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.bsub.done .stitle { color: #a8a8a8; text-decoration: line-through; }
.bmore { font-size: 10px; color: #999; margin-top: 4px; }
.bsub-add { font-size: 10.5px; color: #b8bcc4; margin-top: 6px; cursor: pointer; }
.badd {
background: rgba(255,255,255,.6); border: 1px dashed #d5dae1; border-radius: 8px;
padding: 8px; text-align: center; font-size: 11.5px; color: #b8bcc4; cursor: pointer; flex-shrink: 0;
}
.drag-ghost {
position: fixed; z-index: 999; transform: translate(-50%, -50%); pointer-events: none;
background: rgba(22,119,255,.92); color: #fff; font-size: 12px; padding: 6px 10px;
border-radius: 8px; max-width: 180px; box-shadow: 0 8px 18px rgba(15,23,42,.25);
}
/* ---- 列设置 / 成员页 ---- */
/* fixed 覆盖层要自己让出顶部状态栏 (复用 main.jsx 校准的 --safe-top), 否则与系统工具栏重合 */
.cs-page { position: fixed; inset: 0; z-index: 1000; background: var(--color-bg); display: flex; flex-direction: column; padding-top: var(--safe-top); }
.cs-page .navbar-sub { display: flex; align-items: center; gap: 10px; padding: 12px 14px; background: #fff; border-bottom: 1px solid var(--color-border); flex-shrink: 0; }
.cs-page .navbar-sub .back { font-size: 20px; color: #666; line-height: 1; cursor: pointer; }
.cs-page .navbar-sub .title { font-size: 16px; font-weight: 700; }
.cs-page .navbar-sub .right { margin-left: auto; display: flex; gap: 6px; }
.cs-body { flex: 1 1 auto; min-height: 0; overflow-y: auto; padding-bottom: 20px; }
.set-row {
display: flex; align-items: center; gap: 10px; background: #fff;
padding: 12px 14px; margin-bottom: 1px;
}
.set-row .sname { font-size: 13px; font-weight: 600; width: 64px; flex-shrink: 0; cursor: pointer; }
.set-row .sctrl { flex: 1; display: flex; align-items: center; gap: 8px; justify-content: flex-end; }
.set-hint { font-size: 11px; color: #999; padding: 8px 14px 14px; line-height: 1.7; }
.section-h { font-size: 11px; color: #b8bcc4; letter-spacing: .5px; display: flex; align-items: center; gap: 6px; }
.drop {
border: 1px solid var(--color-border); border-radius: 7px; padding: 4px 6px; font-size: 12px;
color: var(--color-text); background: #fff; min-width: 96px;
}
.drop.off { color: #999; background: #f7f8fa; }
.cs-ord { display: flex; flex-direction: column; font-size: 9px; color: #c0c4cc; line-height: 1.1; }
.cs-ord span { cursor: pointer; }
.cs-del { font-size: 11px; color: #ff3141; cursor: pointer; padding: 2px 4px; }
.cs-count { font-size: 11px; color: #999; margin-left: 8px; }
/* ---- 任务详情 ---- */
.sheet-title { font-size: 16px; font-weight: 700; margin-bottom: 4px; word-break: break-word; }
.sheet-sub { font-size: 11.5px; color: #999; margin-bottom: 14px; }
.field { margin-bottom: 13px; }
.field label { display: block; font-size: 12px; color: var(--color-muted); margin-bottom: 6px; }
.inp {
border: 1px solid var(--color-border); border-radius: 8px; padding: 8px 10px;
font-size: 14px; background: #fff; color: var(--color-text); width: 100%;
font-family: inherit;
}
textarea.inp { resize: vertical; }
.pick-row { display: flex; align-items: center; gap: 10px; padding: 10px 4px; border-bottom: 1px solid var(--color-border); cursor: pointer; }
.pick-row:last-child { border-bottom: none; }
.pick-row .pn { flex: 1; font-size: 13.5px; }
.avatar {
width: 26px; height: 26px; border-radius: 50%; background: #dfe6f0; color: #5a6a80;
font-size: 11px; font-weight: 700; display: flex; align-items: center; justify-content: center; flex-shrink: 0;
}
.ck { width: 18px; height: 18px; border-radius: 50%; border: 1.5px solid #d0d0d0; flex-shrink: 0; position: relative; }
.ck.on { background: var(--color-primary); border-color: var(--color-primary); }
.ck.on::after {
content: ""; position: absolute; left: 5px; top: 2px; width: 4px; height: 8px;
border: solid #fff; border-width: 0 1.5px 1.5px 0; transform: rotate(45deg);
}
.btn-primary { text-align: center; background: var(--color-primary); color: #fff; border-radius: 8px; padding: 10px 0; font-size: 15px; font-weight: 600; margin-top: 18px; cursor: pointer; }
.btn-ghost { text-align: center; border: 1px solid var(--color-border); border-radius: 8px; padding: 9px 0; font-size: 15px; color: var(--color-muted); margin-top: 8px; background: #fff; cursor: pointer; }
.btn-danger { text-align: center; border: 1px solid #ff3141; border-radius: 8px; padding: 9px 0; font-size: 15px; color: #ff3141; margin-top: 8px; background: #fff; cursor: pointer; }
.del-hint { font-size: 11px; font-weight: 400; }
.extra-row { display: flex; gap: 8px; margin-top: 8px; align-items: center; }
.extra-k { flex: 0 0 82px; }
.extra-v { flex: 1; }
.extra-del { border: none; background: none; color: #c0c4cc; font-size: 18px; line-height: 1; cursor: pointer; }
.extras { margin-top: 4px; }
.extras-head {
display: flex; align-items: center; gap: 6px; font-size: 13px; font-weight: 600;
padding: 9px 10px; background: #f7f8fa; border-radius: 8px; cursor: pointer;
}
.extras-head .caret { font-size: 10px; color: #999; }
.extras-head .cv { margin-left: auto; color: #b8bcc4; font-size: 10px; font-weight: 400; }
.extras-body { padding: 4px 0 2px; }
.extras-empty { font-size: 11.5px; color: #b8bcc4; padding: 8px 2px; }
.extras-add { font-size: 12px; color: var(--color-primary); background: none; border: none; padding: 6px 2px; cursor: pointer; }
.tag { font-size: 10px; color: var(--color-primary); background: #f0f5ff; border-radius: 4px; padding: 0 5px; }
.tag.grey { color: #999; background: #f2f3f5; }
/* ---- 空态 / 错误态 ---- */
.empty { text-align: center; padding: 70px 20px; color: #999; }
.empty .ico {
width: 64px; height: 64px; margin: 0 auto 12px; border-radius: 50%; background: #f2f3f5;
display: flex; align-items: center; justify-content: center; color: #c4c8ce; font-size: 30px;
}
.empty .ico.err { background: #fff1f0; color: #ff7875; }
.empty p { font-size: 13px; margin-bottom: 12px; }
/* ---- 任务首屏 (T-VW-08/09/10): 最近项目条 + 当前项目 ---- */
.task-home { flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; }
.rp-bar { display: flex; gap: 8px; padding: 10px 12px 6px; overflow-x: auto; flex-shrink: 0; }
.rp-card {
flex: 0 0 96px; background: #fff; border-radius: 10px; padding: 9px 10px;
box-shadow: 0 1px 2px rgba(0,0,0,.04); cursor: pointer; position: relative;
border: 1.5px solid transparent;
}
.rp-card.on { border-color: var(--color-primary); background: #f5f9ff; }
.rp-card.dragging { opacity: .6; transform: scale(.96); box-shadow: 0 8px 18px rgba(15,23,42,.18); z-index: 5; }
.rp-card .rp-ico {
width: 26px; height: 26px; border-radius: 7px; color: #fff; font-size: 11px;
font-weight: 700; display: flex; align-items: center; justify-content: center;
}
.rp-card .rp-name { font-size: 12px; font-weight: 600; margin-top: 5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.rp-card .rp-meta { font-size: 9.5px; color: #999; margin-top: 1px; }
.rp-card .rp-grip { position: absolute; top: 5px; right: 6px; color: #d3d8de; font-size: 10px; letter-spacing: -1px; }
.rp-more {
flex: 0 0 auto; align-self: stretch; display: flex; flex-direction: column; align-items: center;
justify-content: center; gap: 3px; color: #666; font-size: 10.5px; cursor: pointer; padding: 0 6px;
}
.rp-more .rp-more-ico { width: 30px; height: 30px; border-radius: 50%; background: #f2f3f5; color: #9aa1aa; display: flex; align-items: center; justify-content: center; font-size: 14px; }
.rp-drag-tip { font-size: 9.5px; color: #b8bcc4; padding: 0 14px 2px; flex-shrink: 0; }
/* 首屏内嵌的项目视图 (T-VW-09) */
.pv-embed { flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; }
.task-projbar {
display: flex; align-items: center; gap: 10px; padding: 9px 14px;
background: #fff; border-bottom: 1px solid var(--color-border); flex-shrink: 0; margin-top: 4px;
}
.task-projbar .back { font-size: 20px; color: #666; line-height: 1; cursor: pointer; }
.task-projbar .title { font-size: 15px; font-weight: 700; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.task-projbar .right { margin-left: auto; display: flex; gap: 6px; flex-shrink: 0; }
.pv-embed .task-content { flex: 1 1 auto; }
/* ---- 项目列表拖动排序 (bug#3) ---- */
.proj-item.dragging { opacity: .65; transform: scale(.98); box-shadow: 0 8px 18px rgba(15,23,42,.16); z-index: 5; position: relative; }
/* ---- 项目信息块 (T-PJ-13) ---- */
.note-row { cursor: pointer; }
.note-title { font-size: 13.5px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.note-preview {
font-size: 11.5px; color: #999; margin-top: 3px; overflow: hidden;
text-overflow: ellipsis; white-space: nowrap;
}
/* ---- 通用分段控件 (状态选择等): 当前项高亮 ----
此前只有 .pv-head .seg 有定义, 详情弹层里裸用 .seg 会堆成三行 */
.seg { display: flex; background: #f2f3f5; border-radius: 8px; padding: 3px; }
.seg div { flex: 1; text-align: center; font-size: 13px; padding: 6px 0; border-radius: 6px; color: var(--color-muted); cursor: pointer; }
.seg div.on { background: #fff; color: var(--color-text); font-weight: 600; box-shadow: 0 1px 2px rgba(0,0,0,.06); }
/* ---- 看板卡片 ⋯ 菜单入口 ---- */
.bmore-btn { margin-left: auto; color: #c2c7cf; font-size: 14px; line-height: 1; padding: 2px 4px; cursor: pointer; }
/* ---- ActionSheet 标题 (任务 ⋯ 菜单) ---- */
.as-extra { padding: 4px 0 8px; }
.as-title { font-size: 13px; font-weight: 600; color: var(--color-text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* ---- 拖动手势 (重点设计 §7.1) ----
看板卡片 / 列表行: 纵向滚动归浏览器(pan-y), 水平位移留给拖动换列。
任务区整体禁用长按文字选择与系统菜单, 避免长按拖动变成选中文字 (输入框除外)。 */
.task-content {
-webkit-user-select: none; user-select: none;
-webkit-touch-callout: none;
}
.task-content input, .task-content textarea { -webkit-user-select: text; user-select: text; }
.bcard, .task-row { touch-action: pan-y; }
.bcard.dragging {
opacity: .5; transform: scale(.97); position: relative; z-index: 6;
box-shadow: 0 10px 22px rgba(15,23,42,.22);
}
.task-row.dragging {
opacity: .55; transform: scale(.985); position: relative; z-index: 5;
box-shadow: 0 8px 18px rgba(15,23,42,.16);
}
.drag-pill {
font-size: 11px; font-weight: 600; color: #fff; background: var(--color-primary);
border-radius: 999px; padding: 2px 9px; flex-shrink: 0; white-space: nowrap;
}
.drag-pill.cur { background: #c0c7d0; }
+19
View File
@@ -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())
)
}
+27
View File
@@ -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
}

Some files were not shown because too many files have changed in this diff Show More