31 Commits
Author SHA1 Message Date
jif2.zhang 1cd5b85a4b Merge remote-tracking branch 'origin/master' into dev-0.2
Docker Build and Push / build-image (push) Successful in 1m6s
# Conflicts:
#	CHANGELOG.md
2026-10-25 21:59:52 +08:00
jif2.zhang 8203a7f893 fix(V0.2): 版本规则收尾 —— 手动 OTA 工作流对齐发布说明/commit 口径
Docker Build and Push / build-image (push) Successful in 1m1s
- ota-build.yml: notes 默认取检出 commit 的 message, --commit 写入
  manifest (与 docker-build 一致); 参数加引号防空格断裂
- docker-build.yml: 去掉 head -c 400 字节截断 (会把多字节汉字切成
  非法 UTF-8 导致 latest.json 解析失败), 截断统一由打包脚本按字符处理
- build-ota.mjs: CI 日志输出 build_id 与 commit
2026-10-25 21:40:13 +08:00
jif2.zhang 0f71323c11 docs: CHANGELOG 按程序/Android外壳/Windows外壳 三部分独立记录当前值与变更
Docker Build and Push / build-image (push) Successful in 1m2s
2026-10-25 21:36:01 +08:00
jif2.zhang 93b7c7bb0a docs(changelog): 记录 V0.2.1 —— 主要解决离线编辑与不同终端数据合并问题 2026-10-25 21:34:17 +08:00
jif2.zhang 67be8d8f7b feat(V0.2): 版本方案落地 —— 前端/外壳解绑, build 标识统一, 外壳版本展示
Docker Build and Push / build-image (push) Successful in 1m5s
- check-version: 分组校验 (前端 package.json / 外壳 tauri.conf+Cargo),
  两组不再要求相等; 分支前缀校验只针对前端版本
- build-ota: build_id 改为 YYYYMMDDHHmmss (北京时间, 与前端 BUILD_ID
  同格式); sequence 保持毫秒仅用于判新
- 我的页: 当前版本恢复 v0.2.0 · build <原始BUILD_ID>; 新版本附 OTA
  build_id; 底部替换为外壳版本 (Tauri getVersion, Web 端 —)
- README: 合并遗留 readme 全部内容并按决议重写版本管理章节;
  新增 CHANGELOG.md (PATCH 仅明确提出时自增)
2026-10-25 21:28:06 +08:00
jif2.zhang 06aa48f6ad docs: 版本方案 (V0.2) 整理稿 —— 版本构成/分支映射/OTA 口径/PATCH 记录/展示修复清单, 含待确认问题 A-E
Docker Build and Push / build-image (push) Successful in 1m7s
2026-10-25 20:46:11 +08:00
jif2.zhang 6bd39aeed2 feat(V0.2): 看板列内纵向滚动加固 + 滚动时右缘滚动条 (方案 B)
Docker Build and Push / build-image (push) Successful in 1m6s
- 列体外包 .bcol-main (relative): 叠加自绘细滚动条, 滚动时出现、
  停止约 700ms 后淡出, 宽 3px 不占卡片空间
- bcol-body 补 -webkit-overflow-scrolling/overscroll-behavior,
  高度链显式化 (height:100%), 修真机列内滚不动的问题
- 列头保持固定不随滚动
2026-10-25 18:57:35 +08:00
jif2.zhang fd66c529c2 fix(V0.2): 智能截断去尾部标点; 看板左列收窄; 项目头像三处统一
Docker Build and Push / build-image (push) Successful in 1m7s
- splitTitle: 截断后用 \p{P} 去掉标题尾部连续分隔符 (原字符类重组有
  转义坑, 换 Unicode 属性类)
- 看板卡片左列 (状态点+⋯) 收窄至 14px 并负外距悬进卡片内边距
- 项目头像统一: 新增 .pavatar 基类与 projectIcon.colorOfProject,
  项目管理预览改用同配色/同圆角/居中首字, 修复灰底与文字歪斜
2026-10-25 18:38:16 +08:00
jif2.zhang cee21a39d9 feat(V0.2): 快速添加输入框三行/失焦收起; ⋯菜单点选即收; 项目图标可设图片
Docker Build and Push / build-image (push) Successful in 1m22s
- 快速添加改 3 行 textarea, Enter 创建/Shift+Enter 换行/Esc 关闭,
  焦点离开且无内容时自动收起
- 任务 ⋯ ActionSheet 点选动作后 handler.close() 收回菜单
- Project.icon (TEXT data URL, 迁移 20261025130000): 项目管理可选图片,
  客户端裁为 96x96 cover JPEG (~15KB); 项目列表/最近项目/管理页图标位
  显示图片, 保持原尺寸与圆角; 可恢复默认首字色块
2026-10-25 12:02:29 +08:00
jif2.zhang e3ac00a641 fix(V0.2): OTA 发布说明改为 commit message, sha 单独展示; 版本附构建时间
Docker Build and Push / build-image (push) Successful in 1m2s
- CI: notes 取 git log -1 的 commit message (标题+正文, 截断 400 字),
  commit sha 经 --commit 写入 manifest (不再把裸 sha 塞进发布说明)
- build-ota: 新增 --commit 参数; latest.json 增加 commit 字段
- 服务端 /ota/check 与 /manifest-web 透传 commit 与 sequence
- 我的页: 当前版本/新版本后附 seq 时间戳 (MM-dd HH:mm),
  发布说明下方新增「提交」行显示短 sha
2026-10-25 11:56:04 +08:00
jif2.zhang 74e1e7dd13 feat(V0.2): 标题30字+智能拆分 / 时间标记 MM-dd / 时间词前缀 (T-TK-14~16)
Docker Build and Push / build-image (push) Successful in 1m25s
- 标题上限收紧至 30 字 (服务端校验+详情编辑框); 快速添加输入框
  放宽到 200 字: 超长时在 30 字内分隔符处截断为标题, 完整原文进描述
- Task.markDate (DateTime, 迁移 20261025120000): 详情页 DatePicker
  选择/清除, 列表行与看板卡显示 MM-dd 小标签
- 快速添加时间词前缀: 今天/明天/后天/本周/下周/本月/下月 开头
  (后跟分隔符) 自动设为时间标记并移出标题; 本周=本周日, 本月=月末
- 需求/功能/数据库设计文档同步更新 T-TK-14~16 与 Task 表字段
2026-10-25 11:47:44 +08:00
jif2.zhang fd27242e4e fix(V0.2): 全局禁用非编辑框文字选中 + 标题类文本溢出省略
Docker Build and Push / build-image (push) Successful in 1m6s
- * 级 user-select/touch-callout 禁用, input/textarea/contenteditable 放行
- 项目列表标题、项目管理导航标题、看板列名: 单行省略
- 弹层 sheet-title 限两行截断, sheet-sub 单行省略 (任务详情 200 字标题
  不再撑爆弹层头部)
2026-10-25 10:41:29 +08:00
jif2.zhang 20cc1ccde9 feat(V0.2): 列表/看板负责人改为头像 (姓氏首字+按名稳定配色)
Docker Build and Push / build-image (push) Successful in 59s
2026-10-25 03:04:15 +08:00
jif2.zhang 9401bd2b3d refactor(V0.2): 列表行等高 (无标签保留元信息行); 看板类型标签与负责人同行
Docker Build and Push / build-image (push) Successful in 1m1s
2026-10-25 02:59:52 +08:00
jif2.zhang fb9e2350b3 feat(V0.2): 任务类型标签 —— 项目级自定义 + 任务标记 + 列表/看板展示
Docker Build and Push / build-image (push) Successful in 1m21s
- 数据: Project.taskKinds (JSON, 未配置回落默认 需求/Bug/FEATURE/优化),
  Task.kind (≤20字); 迁移 20261025030000_task_kind
- 服务: 项目 PUT 支持整体替换 taskKinds (去重/上限30/至少1个);
  任务 create/update 支持 kind (空串清空)
- 项目管理: 新增「任务类型」分区, chips 展示 + 弹层编辑 (删除/新增)
- 任务详情: 新增「类型」字段, 项目类型 chips 点选, 可清空
- 展示: 列表行左下与看板卡左下显示彩色小标签
  (需求蓝/Bug红/FEATURE紫/优化橙, 自定义类型灰)
2026-10-25 02:53:25 +08:00
jif2.zhang 2e6cc986a0 refactor(V0.2): 列表主行去掉状态圆点, 元信息行仅在有成员时显示
Docker Build and Push / build-image (push) Successful in 1m1s
2026-10-25 02:44:03 +08:00
jif2.zhang d20837f9f1 refactor(V0.2): 看板 ⋯ 沉到左下角; 列表负责人右下角, 去掉状态文字
Docker Build and Push / build-image (push) Successful in 59s
- 看板卡片左列改为上下撑满: 状态点在上, ⋯ 落到卡片左下角
- 列表行右列改为上下撑满: ⋯ 在右上, 负责人落到右下角
- 列表行第二行不再显示未开始/执行中/完成小字, 只留状态点与成员
2026-10-25 02:40:02 +08:00
jif2.zhang c0b55277a4 fix(V0.2): 列表组内排序乐观更新未重排数组, 松手后界面不动
Docker Build and Push / build-image (push) Successful in 1m0s
列表分组渲染按 tasks 数组顺序, reorderTask 此前只给任务打新
sortOrder 字段而保持原数组顺序 —— 松手后界面无任何变化, 看起来
就是组内无法排序 (数据其实已提交)。改为直接按新顺序重建数组,
不在分组内的任务 (孤儿) 追加在尾部。
2026-10-25 02:37:39 +08:00
jif2.zhang 49037371f8 refactor(V0.2): 看板卡片左列布局 / 列表负责人右移 / 把手再收窄
Docker Build and Push / build-image (push) Successful in 1m0s
- 看板卡片: 状态点与 ⋯ 竖排移到卡片左侧, 负责人+成员靠右, 标题独占中间
- 列表行: 负责人从第二行元信息移到行右侧 (⋯ 左边)
- ⠿ 把手: 宽度收到约 10px, 负外距悬进卡片内边距, 基本不占横向空间
2026-10-25 02:32:49 +08:00
jif2.zhang 2fa41c774b fix(V0.2): 拖动浮层残留蓝色背景 —— 类名与遗留 .drag-ghost 规则冲突
Docker Build and Push / build-image (push) Successful in 1m3s
globals.css 里有一条无人使用的旧 .drag-ghost 规则 (蓝色气泡,
max-width 180px, 看板早期方案遗留), 排在新增规则之后同特异性胜出,
克隆卡片容器被套上蓝色背景并被压缩到 180px 宽, 即真机上手下面的
蓝色 div。删除遗留规则; 新规则显式清零背景/内边距并禁用
tap-highlight/user-select (克隆脱离 .mv-view/.task-content 祖先后
需自带防选中)。CDP 实测拖动中容器背景已透明、宽度正常。
2026-10-25 02:28:09 +08:00
jif2.zhang 1673cbdb7d refactor(V0.2): 拖动交互收紧 —— 仅把手触发 / 缩小把手 / 去多余元素 / 打卡跨组
Docker Build and Push / build-image (push) Successful in 1m3s
- 打卡与列表: 去掉卡片整体长按拖动路径, 只有 ⠿ 把手可拖; 把手缩小
- 列表行去掉跟动的蓝色目标列徽标 (drag-pill), 占位/克隆浮层承担全部反馈
- 打卡支持跨组拖动: 落到其他周期组的目标卡 = 改周期 + 落位一次完成
  (updateHabit period + reorder), 组内排序不变
- 看板: 去掉 ⠿ 把手, 恢复长按拖动
2026-10-25 02:19:03 +08:00
jif2.zhang 5066007d9d feat(V0.2): 克隆卡片跟手浮层 + 列表组内排序 + 把手统一移到最左
Docker Build and Push / build-image (push) Successful in 1m1s
- 浮层改为激活时克隆原卡片 (DOM clone, 淡化+投影) 挂 body 顶层,
  移动直接改 transform —— 替代蓝色信息条, 所见即所拖
- 任务列表支持组内排序: 拖到本组某行上/下半显示插入指示, 松手提交
  POST /projects/:id/tasks/reorder (顶层任务按展平顺序重写 sortOrder);
  拖到其他分组仍为换列
- ⠿ 把手统一移到卡片最左: 打卡 / 列表任务行 / 看板卡片 (看板新增把手
  即按即拖路径, 与列表同款 touchstart preventDefault 双保险)
- useColumnDrag onDrop 经 ref 桥接防陈旧闭包
2026-10-25 02:07:58 +08:00
jif2.zhang 9a8313173d fix(V0.2): 浮层 Portal 修复被遮挡 + 列表落点改为手指所在分组
Docker Build and Push / build-image (push) Successful in 1m1s
- 打卡/任务浮层 createPortal 挂 document.body: 页签滑动容器 transform
  破坏 position:fixed 包含块, 浮层被压进卡片流只能看到边缘
- 任务列表落点: 弃用 80px/列步进换算 (视觉位置与换算不一致, 落点随机),
  改为 elementFromPoint 命中 [data-column-id] 分组 —— 手指在哪个分组
  松手就落到哪, 与看板行为一致; 划出分组时保持上一目标
2026-10-25 01:51:17 +08:00
jif2.zhang 83cc218c5e feat(V0.2): 拖动跟手浮层 —— 半透明副本随指针, 原位虚线占位
Docker Build and Push / build-image (push) Successful in 1m5s
- useRowDrag/useHabitDrag 暴露 ghostRef, 拖动激活与移动时直接改
  transform (绕过 React 重渲染, 保证跟手流畅)
- 打卡浮层带颜色点 + 名称; 任务列表浮层带任务名 + 实时目标列名
- 原位卡片降为低透明度虚线占位, 落点指示不变
2026-10-25 01:42:29 +08:00
jif2.zhang 215263bf2f fix(V0.2): 打卡排序闭包陈旧 + 把手拖动双保险
Docker Build and Push / build-image (push) Successful in 1m1s
- useHabitDrag: onDrop 经 ref 桥接实时闭包 —— bind/bindHandle 空依赖
  useCallback 固化了首次渲染(groups=[] 数据未加载)的落位函数, 松手
  静默早退, 顺序永远不变
- 把手路径挂非 passive touchstart preventDefault: 规范上禁止浏览器对
  整个触摸的默认处理 (含滚动), 与 touch-action:none 双保险
- 把手触区放大 (36px 高), 降低按到行体触发不可靠长按路径的概率
2026-10-25 01:31:27 +08:00
jif2.zhang 2725f00f68 fix(V0.2): 拖动可靠性重做 —— 把手入口 + 打卡排序接入实际渲染视图
Docker Build and Push / build-image (push) Successful in 1m1s
问题定位:
- 打卡页实际渲染的是 MergedView (CheckinPage 路由), 上次改的 HabitView
  是未使用的旧组件, 打包时被 tree-shake, 线上自然无任何效果
- 任务列表长按抬起后一动即脱手: touch-action:pan-y 下手指出现纵向分量,
  WebView/渲染器直接把触摸判给滚动并 pointercancel, touchmove preventDefault
  拦不住已决定的滚动 (看板可用只因该模式下页面无纵向可滚祖先)

修复:
- 新增 ⠿ 拖动把手 (touch-action:none): 浏览器按规范完全不接管该触摸,
  按下立即拖动, CDP 触摸仿真验证: 垂直拖动穿过可滚列表正常落点,
  跨组(每天/每周/每月)正确拒绝; 任务行把手拖动换列, 打卡卡片把手组内排序
- useRowDrag/useHabitDrag 抽出 armListeners, 新增 bindHandle 立即激活路径
- MergedView: 组内排序改按 sortOrder (服务端已返回), 拖动落位乐观更新 +
  POST /checkin/habits/reorder, 失败回滚; 打卡点击加拖动余波保护
- 长按路径保留为补充入口 (不可滚场景仍可用)
2026-10-25 01:21:42 +08:00
jif2.zhang b1c1b16e66 ci: minisign 静态包混多平台二进制, 试运行挑可用者, 失败回退 apt
Docker Build and Push / build-image (push) Successful in 3m31s
2026-10-25 00:56:35 +08:00
jif2.zhang 51256335ba ci: 优化构建速度与资源消耗
Docker Build and Push / build-image (push) Failing after 39s
- docker-build: 停用 docker system prune --volumes (保住 build 层缓存), 改为 image prune -f
- 新增 .dockerignore, node_modules/.git/src-tauri 等不再进 build context
- 前端只构建一次: CI 构建 dist 供 OTA 与镜像复用 (PREBUILT_DIST 构建参数)
- Dockerfile: npm install → npm ci + BuildKit cache mount
- setup-node 开启 npm 缓存; master/android-* 加 concurrency 取消旧 run
- android: 只构建 aarch64; tauri target 缓存 key 按 Cargo.lock hash
- minisign 改为静态二进制下载, 去掉 apt-get
2026-10-25 00:48:50 +08:00
jif2.zhang 20a5105c23 refactor(V0.2): 详情页精简 + 列表行样式调整
Docker Build and Push / build-image (push) Successful in 4m20s
- 任务详情: 移除底部「拷贝到其他项目 / 删除任务」按钮 (⋯ 菜单仍保留入口), 清理相关死代码
- 列表子任务: 改为与普通任务同构的两行布局 (标题 + 左侧状态点/状态文字, 字体略小)
- 列表任务卡片第二行不再显示看板分组标签
2026-10-25 00:23:01 +08:00
jif2.zhang 9467d4c242 feat(V0.2): 列表拖动修复 + 打卡项组内拖动排序
- 任务列表/看板拖动: 激活后用非 passive touchmove preventDefault 拦截浏览器滚动接管, 修复列表长按抬起后 pointercancel 导致无法真正拖动
- 打卡: 列表按周期分组展示(每天/每周/每月/每年), 长按拖动组内排序(上下落点指示), 新增 POST /checkin/habits/reorder 重写 sortOrder; 新建项排到末尾
- 打卡卡片加 touch-action/user-select 防长按误触; 拖动结束 350ms 内抑制点击余波
2026-10-25 00:06:44 +08:00
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
61 changed files with 4525 additions and 2637 deletions
+11
View File
@@ -0,0 +1,11 @@
# 大目录/与镜像无关的内容不进 build context
# 注意: dist/ 和 ota-package/ 不能忽略 —— CI 预构建产物要通过 context 进入镜像
.git
.gitea
.local
.pnpm-store
node_modules
src-tauri
docs
*.log
readme
+190 -182
View File
@@ -1,182 +1,190 @@
name: Android Build & Publish name: Android Build & Publish
# 仅在 android-* 分支 push 时触发 (如 android/release, android-1.0) # 仅在 android-* 分支 push 时触发 (如 android/release, android-1.0)
# 构建 release APK (keystore 签名) 并上传到 Gitea Generic Package Registry # 构建 release APK (keystore 签名) 并上传到 Gitea Generic Package Registry
on: on:
push: push:
branches: branches:
- 'android-*' - 'android-*'
jobs: # 同分支连续 push 时取消旧的构建, 省资源也避免上传过时 APK
build-android: concurrency:
runs-on: ubuntu-latest group: android-${{ github.ref_name }}
# 需要写权限: 上传 packages cancel-in-progress: true
permissions:
contents: read jobs:
packages: write build-android:
runs-on: ubuntu-latest
env: # 需要写权限: 上传 packages
# 固定工具链版本, 便于 actions/cache 命中 permissions:
# Android 后端地址 (bake 进 APK); 与 scripts/wsl/build-apk.sh 保持一致 contents: read
VITE_API_BASE: http://bh.vps.honor3.com:3001 packages: write
steps: env:
- name: Checkout # 固定工具链版本, 便于 actions/cache 命中
uses: https://gitea.com/actions/checkout@v4 # Android 后端地址 (bake 进 APK); 与 scripts/wsl/build-apk.sh 保持一致
VITE_API_BASE: http://bh.vps.honor3.com:3001
- name: Setup Node
uses: https://gitea.com/actions/setup-node@v4 steps:
with: - name: Checkout
node-version: 22 uses: https://gitea.com/actions/checkout@v4
cache: 'npm'
- name: Setup Node
# 版本号一致性: package.json / tauri.conf.json / Cargo.toml / Cargo.lock 必须完全相等。 uses: https://gitea.com/actions/setup-node@v4
# tauri.conf.json 的 version 就是 APK 的 versionName, 漏改会打出一个版本号错误的安装包。 with:
- name: Check version consistency node-version: 22
run: node scripts/ci/check-version.mjs cache: 'npm'
- name: Setup JDK 17 # 版本号一致性: package.json / tauri.conf.json / Cargo.toml / Cargo.lock 必须完全相等。
uses: https://gitea.com/actions/setup-java@v4 # tauri.conf.json 的 version 就是 APK 的 versionName, 漏改会打出一个版本号错误的安装包。
with: - name: Check version consistency
distribution: temurin run: node scripts/ci/check-version.mjs
java-version: '17'
- name: Setup JDK 17
# ---------- 缓存: 命中后跳过下面的安装 ---------- uses: https://gitea.com/actions/setup-java@v4
- name: Cache Android SDK & NDK with:
id: cache-android distribution: temurin
uses: https://gitea.com/actions/cache@v4 java-version: '17'
with:
path: ~/android-sdk # ---------- 缓存: 命中后跳过下面的安装 ----------
key: android-sdk-ndk-27.2-${{ runner.os }} - name: Cache Android SDK & NDK
id: cache-android
- name: Cache Rust toolchain uses: https://gitea.com/actions/cache@v4
id: cache-rust with:
uses: https://gitea.com/actions/cache@v4 path: ~/android-sdk
with: key: android-sdk-ndk-27.2-${{ runner.os }}
path: |
~/.cargo - name: Cache Rust toolchain
~/.rustup id: cache-rust
# 工具链升级时 bump 版本号使缓存失效重装 (含 cargo-ndk) uses: https://gitea.com/actions/cache@v4
key: rust-android-v1-${{ runner.os }} with:
path: |
- name: Cache Tauri cargo target ~/.cargo
uses: https://gitea.com/actions/cache@v4 ~/.rustup/toolchains
with: # 工具链升级时 bump 版本号使缓存失效重装 (含 cargo-ndk)
path: src-tauri/target key: rust-android-v1-${{ runner.os }}
key: tauri-target-android-${{ runner.os }}
- name: Cache Tauri cargo target
# ---------- 安装工具链 (仅缓存未命中时) ---------- uses: https://gitea.com/actions/cache@v4
- name: Install Rust + Android targets with:
if: steps.cache-rust.outputs.cache-hit != 'true' path: src-tauri/target
run: | # 依赖变化 → 新 key; 命不中时回退最近一次的 target 目录做增量编译
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable key: tauri-target-android-${{ hashFiles('src-tauri/Cargo.lock') }}
echo "$HOME/.cargo/bin" >> $GITHUB_PATH restore-keys: tauri-target-android-
. "$HOME/.cargo/env"
rustup target add aarch64-linux-android armv7-linux-androideabi x86_64-linux-android i686-linux-android # ---------- 安装工具链 (仅缓存未命中时) ----------
cargo install cargo-ndk --locked - name: Install Rust + Android targets
if: steps.cache-rust.outputs.cache-hit != 'true'
- name: Install Android SDK & NDK run: |
if: steps.cache-android.outputs.cache-hit != 'true' curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable
run: | echo "$HOME/.cargo/bin" >> $GITHUB_PATH
export ANDROID_HOME="$HOME/android-sdk" . "$HOME/.cargo/env"
sudo apt-get update # 只构建 aarch64 (现代 Android 手机); 需要模拟器/x86 设备时再加回其他 target
sudo apt-get install -y --no-install-recommends unzip rustup target add aarch64-linux-android
mkdir -p "$ANDROID_HOME/cmdline-tools" cargo install cargo-ndk --locked
wget -q https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip -O /tmp/cmdline-tools.zip
unzip -q /tmp/cmdline-tools.zip -d /tmp/ - name: Install Android SDK & NDK
mv /tmp/cmdline-tools "$ANDROID_HOME/cmdline-tools/latest" if: steps.cache-android.outputs.cache-hit != 'true'
export PATH="$ANDROID_HOME/cmdline-tools/latest/bin:$PATH" run: |
yes | sdkmanager --licenses >/dev/null || true export ANDROID_HOME="$HOME/android-sdk"
sdkmanager --install \ sudo apt-get update
"platforms;android-36" \ sudo apt-get install -y --no-install-recommends unzip
"build-tools;36.0.0" \ mkdir -p "$ANDROID_HOME/cmdline-tools"
"platform-tools" \ wget -q https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip -O /tmp/cmdline-tools.zip
"ndk;27.2.12479018" \ unzip -q /tmp/cmdline-tools.zip -d /tmp/
"cmake;3.22.1" mv /tmp/cmdline-tools "$ANDROID_HOME/cmdline-tools/latest"
export PATH="$ANDROID_HOME/cmdline-tools/latest/bin:$PATH"
- name: Install frontend deps yes | sdkmanager --licenses >/dev/null || true
run: npm ci --no-audit --no-fund sdkmanager --install \
"platforms;android-36" \
# ---------- 全局 Android 环境 (对后续所有 step 生效, 无论缓存是否命中) ---------- "build-tools;36.0.0" \
- name: Set Android env "platform-tools" \
run: | "ndk;27.2.12479018" \
echo "ANDROID_HOME=$HOME/android-sdk" >> "$GITHUB_ENV" "cmake;3.22.1"
echo "ANDROID_NDK_HOME=$HOME/android-sdk/ndk/27.2.12479018" >> "$GITHUB_ENV"
echo "$HOME/android-sdk/cmdline-tools/latest/bin" >> "$GITHUB_PATH" - name: Install frontend deps
echo "$HOME/android-sdk/platform-tools" >> "$GITHUB_PATH" run: npm ci --no-audit --no-fund
# ---------- 构建 ---------- # ---------- 全局 Android 环境 (对后续所有 step 生效, 无论缓存是否命中) ----------
- name: Init Android project - name: Set Android env
run: | run: |
# tauri android init 需要 cargo (运行 cargo metadata)。 echo "ANDROID_HOME=$HOME/android-sdk" >> "$GITHUB_ENV"
# Rust 工具链缓存命中时 "Install Rust" 步骤被跳过, 不会把 cargo 加进全局 PATH, echo "ANDROID_NDK_HOME=$HOME/android-sdk/ndk/27.2.12479018" >> "$GITHUB_ENV"
# 因此这里显式加载 cargo 环境 (与 Build release APK 步骤保持一致)。 echo "$HOME/android-sdk/cmdline-tools/latest/bin" >> "$GITHUB_PATH"
. "$HOME/.cargo/env" echo "$HOME/android-sdk/platform-tools" >> "$GITHUB_PATH"
cd src-tauri
npx tauri android init --ci # ---------- 构建 ----------
- name: Init Android project
- name: Inject release signing config run: |
run: bash scripts/ci/android-signing.sh # tauri android init 需要 cargo (运行 cargo metadata)。
env: # Rust 工具链缓存命中时 "Install Rust" 步骤被跳过, 不会把 cargo 加进全局 PATH,
ANDROID_KEYSTORE_B64: ${{ secrets.ANDROID_KEYSTORE_B64 }} # 因此这里显式加载 cargo 环境 (与 Build release APK 步骤保持一致)。
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }} . "$HOME/.cargo/env"
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }} cd src-tauri
npx tauri android init --ci
- name: Build release APK
run: | - name: Inject release signing config
export ANDROID_HOME="$HOME/android-sdk" run: bash scripts/ci/android-signing.sh
export ANDROID_NDK_HOME="$ANDROID_HOME/ndk/27.2.12479018" env:
export PATH="$ANDROID_HOME/cmdline-tools/latest/bin:$ANDROID_HOME/platform-tools:$ANDROID_NDK_HOME/toolchains/llvm/prebuilt/linux-x86_64/bin:$PATH" ANDROID_KEYSTORE_B64: ${{ secrets.ANDROID_KEYSTORE_B64 }}
export CARGO_HOME="$HOME/.cargo" ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
. "$HOME/.cargo/env" ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
cd src-tauri
npx tauri android build --apk - name: Build release APK
# 断言签名配置仍在且 release 块实际引用了它 (防止 tauri 重新生成 gen/android 或正则未命中) run: |
if ! grep -q 'create("release")' gen/android/app/build.gradle.kts || ! grep -q 'signingConfig = signingConfigs.getByName("release")' gen/android/app/build.gradle.kts; then export ANDROID_HOME="$HOME/android-sdk"
echo "ERROR: 构建后签名配置丢失/未生效, APK 未签名" >&2 export ANDROID_NDK_HOME="$ANDROID_HOME/ndk/27.2.12479018"
exit 1 export PATH="$ANDROID_HOME/cmdline-tools/latest/bin:$ANDROID_HOME/platform-tools:$ANDROID_NDK_HOME/toolchains/llvm/prebuilt/linux-x86_64/bin:$PATH"
fi export CARGO_HOME="$HOME/.cargo"
. "$HOME/.cargo/env"
- name: Locate APK cd src-tauri
id: apk npx tauri android build --apk --target aarch64
run: | # 断言签名配置仍在且 release 块实际引用了它 (防止 tauri 重新生成 gen/android 或正则未命中)
# 优先 universal 已签名 APK, 找不到再回退到其他已签名 APK if ! grep -q 'create("release")' gen/android/app/build.gradle.kts || ! grep -q 'signingConfig = signingConfigs.getByName("release")' gen/android/app/build.gradle.kts; then
APK="" echo "ERROR: 构建后签名配置丢失/未生效, APK 未签名" >&2
UNIVERSAL=$(find src-tauri/gen/android/app/build/outputs/apk/universal/release -name '*.apk' ! -name '*unsigned*' 2>/dev/null | head -1) exit 1
if [ -n "$UNIVERSAL" ]; then fi
APK="$UNIVERSAL"
else - name: Locate APK
APK=$(find src-tauri/gen/android/app/build/outputs/apk -name '*.apk' ! -name '*unsigned*' | head -1) id: apk
fi run: |
if [ -z "$APK" ]; then # 优先 universal 已签名 APK, 找不到再回退到其他已签名 APK
echo "ERROR: 未找到已签名 APK" >&2 APK=""
exit 1 UNIVERSAL=$(find src-tauri/gen/android/app/build/outputs/apk/universal/release -name '*.apk' ! -name '*unsigned*' 2>/dev/null | head -1)
fi if [ -n "$UNIVERSAL" ]; then
echo "path=$APK" >> $GITHUB_OUTPUT APK="$UNIVERSAL"
ls -lh "$APK" else
APK=$(find src-tauri/gen/android/app/build/outputs/apk -name '*.apk' ! -name '*unsigned*' | head -1)
# ---------- 上传到 Gitea Generic Package ---------- fi
- name: Publish APK to Gitea packages if [ -z "$APK" ]; then
env: echo "ERROR: 未找到已签名 APK" >&2
# 注意: 不能用 GITEA_TOKEN (内置 job token, 无组织 packages 写权限, 且命名规则禁止 GITEA_ 前缀) exit 1
# 用一个自定义名 secret 存放有 packages 写权限的 user token fi
PACKAGE_TOKEN: ${{ secrets.PACKAGE_TOKEN }} echo "path=$APK" >> $GITHUB_OUTPUT
APK_PATH: ${{ steps.apk.outputs.path }} ls -lh "$APK"
run: |
set -euo pipefail # ---------- 上传到 Gitea Generic Package ----------
# 包版本: 用分支名去掉 android- 前缀 + 短 SHA, 保证每次 push 唯一 - name: Publish APK to Gitea packages
BRANCH="${GITHUB_REF_NAME#android-}" env:
PKG_VERSION="${BRANCH}-${GITHUB_SHA:0:8}" # 注意: 不能用 GITEA_TOKEN (内置 job token, 无组织 packages 写权限, 且命名规则禁止 GITEA_ 前缀)
FILE_NAME="iboard-$(basename "$APK_PATH")" # 用一个自定义名 secret 存放有 packages 写权限的 user token
OWNER="${GITHUB_REPOSITORY_OWNER}" PACKAGE_TOKEN: ${{ secrets.PACKAGE_TOKEN }}
PKG_NAME="iboard" APK_PATH: ${{ steps.apk.outputs.path }}
# Gitea Generic Package Registry 上传端点 (PUT) run: |
# 注意: GITHUB_SERVER_URL 是仓库 web 根地址, 例如 http://git.honor3.com set -euo pipefail
URL="${GITHUB_SERVER_URL}/api/packages/${OWNER}/generic/${PKG_NAME}/${PKG_VERSION}/${FILE_NAME}" # 包版本: 用分支名去掉 android- 前缀 + 短 SHA, 保证每次 push 唯一
echo "上传到: ${URL}" BRANCH="${GITHUB_REF_NAME#android-}"
curl -sS --fail-with-body \ PKG_VERSION="${BRANCH}-${GITHUB_SHA:0:8}"
-H "Authorization: token ${PACKAGE_TOKEN}" \ FILE_NAME="iboard-$(basename "$APK_PATH")"
--upload-file "$APK_PATH" \ OWNER="${GITHUB_REPOSITORY_OWNER}"
"${URL}" PKG_NAME="iboard"
echo "" # Gitea Generic Package Registry 上传端点 (PUT)
echo "已发布: ${URL}" # 注意: GITHUB_SERVER_URL 是仓库 web 根地址, 例如 http://git.honor3.com
URL="${GITHUB_SERVER_URL}/api/packages/${OWNER}/generic/${PKG_NAME}/${PKG_VERSION}/${FILE_NAME}"
echo "上传到: ${URL}"
curl -sS --fail-with-body \
-H "Authorization: token ${PACKAGE_TOKEN}" \
--upload-file "$APK_PATH" \
"${URL}"
echo ""
echo "已发布: ${URL}"
+149 -114
View File
@@ -1,114 +1,149 @@
name: Docker Build and Push name: Docker Build and Push
# 仅主分支 (master) push 触发部署; 其他分支一律不触发 (见 readme「分支规则」) # 仅主分支 (master) push 触发部署; 其他分支一律不触发 (见 readme「分支规则」)
# 每次提交: 构建前端 + 生成 OTA 升级包 (签名) → 打进 Docker 镜像 → 自动升级数据库 → 部署 # 每次提交: 构建前端 + 生成 OTA 升级包 (签名) → 打进 Docker 镜像 → 自动升级数据库 → 部署
# OTA 随镜像发布: 容器内 OTA_PACKAGE_DIR=/app/ota-package 指向打包的 ota-package/ # OTA 随镜像发布: 容器内 OTA_PACKAGE_DIR=/app/ota-package 指向打包的 ota-package/
# #
# 分支策略 (2026-10 调整): # 分支策略 (2026-10 调整):
# - master = 主分支, 唯一部署出口, push 即发布生产 (容器 iboard, 宿主 3001) # - master = 主分支, 唯一部署出口, push 即发布生产 (容器 iboard, 宿主 3001)
# - dev-* = 开发集成分支, 只做 CI 校验 (构建/版本一致性), **不再自动部署**; # - dev-* = 开发集成分支, 只做 CI 校验 (构建/版本一致性), **不再自动部署**;
# 要上线必须先合并到 master 再推 master # 要上线必须先合并到 master 再推 master
# - tmp-* = 单个问题的临时分支, 合到 dev-* 后删除 # - tmp-* = 单个问题的临时分支, 合到 dev-* 后删除
# - android-*= 只用于触发 APK 外壳构建, 不部署; 改动前须与 master 同步 # - android-*= 只用于触发 APK 外壳构建, 不部署; 改动前须与 master 同步
# #
# 历史: 之前 dev-* 与 master 共用同一套生产环境 (推送即发布), 容易把半成品带上线, # 历史: 之前 dev-* 与 master 共用同一套生产环境 (推送即发布), 容易把半成品带上线,
# 现已取消 dev-* 的 push 触发。 # 现已取消 dev-* 的 push 触发。
on: on:
push: push:
branches: branches:
- master - master
jobs: # 连续 push master 时取消还在跑的旧部署, 避免排队执行过时版本的完整部署
build-image: concurrency:
runs-on: ubuntu-latest group: deploy-master
env: cancel-in-progress: true
# 关键: 显式固定 Android/OTA 后端地址, 覆盖 runner 环境里可能存在的
# 其他 VITE_API_BASE 值。否则 OTA 包里的前端可能连错后端, 导致 jobs:
# "OTA 更新后登录失败/进不去内部"。与 android-build.yml 保持一致。 build-image:
VITE_API_BASE: http://bh.vps.honor3.com:3001 runs-on: ubuntu-latest
# OTA bundle 要求的最低原生二进制版本。 env:
# 与 package.json 的应用版本是**两个概念**: 前端 OTA 不碰原生层, # 关键: 显式固定 Android/OTA 后端地址, 覆盖 runner 环境里可能存在的
# 现有 APK 仍是 0.1.0, 所以这里固定 0.1.0 (否则旧 APK 会被判定为不兼容而收不到更新)。 # 其他 VITE_API_BASE 值。否则 OTA 包里的前端可能连错后端, 导致
# 重打 APK 并确认原生层无破坏后, 再提升此值。 # "OTA 更新后登录失败/进不去内部"。与 android-build.yml 保持一致。
OTA_MIN_BINARY: '0.1.0' VITE_API_BASE: http://bh.vps.honor3.com:3001
steps: # OTA bundle 要求的最低原生二进制版本。
- uses: https://gitea.com/actions/checkout@v4 # 与 package.json 的应用版本是**两个概念**: 前端 OTA 不碰原生层,
# 现有 APK 仍是 0.1.0, 所以这里固定 0.1.0 (否则旧 APK 会被判定为不兼容而收不到更新)。
- name: Setup Node # 重打 APK 并确认原生层无破坏后, 再提升此值。
uses: https://gitea.com/actions/setup-node@v4 OTA_MIN_BINARY: '0.1.0'
with: steps:
node-version: 22 - uses: https://gitea.com/actions/checkout@v4
- name: Check version consistency & branch - name: Setup Node
# 1) package.json / tauri.conf.json / Cargo.toml / Cargo.lock 四处版本必须完全相等且不带 -ota uses: https://gitea.com/actions/setup-node@v4
# (tauri.conf.json 的 version 就是 APK 的 versionName) with:
# 2) 防止 OTA 版本号与分支错位: OTA 包的 version 直接取自 package.json, node-version: 22
# dev-0.2 分支上必须是 0.2.x, 否则线上 OTA 会报成上一个版本的号 cache: 'npm'
# (master 分支跳过分支前缀校验, 但四处一致性照样强制)
env: - name: Check version consistency & branch
GITHUB_REF_NAME: ${{ github.ref_name }} # 1) package.json / tauri.conf.json / Cargo.toml / Cargo.lock 四处版本必须完全相等且不带 -ota
run: node scripts/ci/check-version.mjs # (tauri.conf.json 的 version 就是 APK 的 versionName)
# 2) 防止 OTA 版本号与分支错位: OTA 包的 version 直接取自 package.json,
- name: Install deps # dev-0.2 分支上必须是 0.2.x, 否则线上 OTA 会报成上一个版本的号
run: npm ci --no-audit --no-fund # (master 分支跳过分支前缀校验, 但四处一致性照样强制)
env:
- name: Build frontend GITHUB_REF_NAME: ${{ github.ref_name }}
# dist/ 供 OTA 打包用 (Dockerfile 内也会构建一份) run: node scripts/ci/check-version.mjs
run: npm run build
- name: Install deps
- name: Install minisign run: npm ci --no-audit --no-fund
run: sudo apt-get update && sudo apt-get install -y minisign
- name: Build frontend
- name: Build & sign OTA package # 全流程唯一一次前端构建: dist/ 既供 OTA 打包, 也通过 build context 进入镜像
# 每次主分支提交生成 OTA 升级包, sequence 默认时间戳 (单调递增) # (docker build 传 PREBUILT_DIST=true 跳过镜像内的重复构建)
# 产物 ota-package/ (tar.gz + latest.json + .minisig) 进入 docker build context, 由 Dockerfile COPY 进镜像 run: npm run build
env:
MINISIGN_PRIVATE_KEY: ${{ secrets.MINISIGN_PRIVATE_KEY }} - name: Install minisign
OTA_NOTES: ${{ github.sha }} # 静态二进制直接下载, 免去每次 apt-get update + install
run: | # 注意: release tarball 是 macOS 上打包的, 混有多个平台的 minisign 二进制,
set -euo pipefail # 逐个试运行挑出本机可执行的, 都不行再回退 apt
node scripts/build-ota.mjs --out ota-package --notes ${OTA_NOTES} --min-binary ${OTA_MIN_BINARY} run: |
echo "===== latest.json =====" set -euo pipefail
cat ota-package/latest.json mkdir -p /tmp/ms
echo "===== bundle sha256 =====" curl -sL https://github.com/jedisct1/minisign/releases/download/0.12/minisign-0.12-linux.tar.gz | tar xz -C /tmp/ms
sha256sum ota-package/iboard-ota.tar.gz BIN=""
for c in $(find /tmp/ms -type f -name minisign); do
- name: Build Docker image if "$c" -v >/dev/null 2>&1; then BIN="$c"; break; fi
# DATABASE_URL 仅在 prisma generate 阶段需要, 可用任何合法占位串 done
run: docker build -t iboard:latest --build-arg DATABASE_URL=mysql://build:build@127.0.0.1:3306/build . if [ -n "$BIN" ]; then
sudo install -m755 "$BIN" /usr/local/bin/minisign
- name: List images else
run: docker images echo "未找到可执行的静态二进制, 回退 apt 安装"
sudo apt-get update && sudo apt-get install -y minisign
- name: Apply DB migrations fi
# 数据库自动升级: 按 prisma/migrations/ 下的迁移顺序应用 (生产库由 master 部署时统一升级) minisign -v
# migrate deploy 幂等, 无新迁移时为 no-op
# 位置关键: 在 "Remove old container" 之前 —— 建表/改表成功后才切换流量, - name: Build & sign OTA package
# 迁移失败则整个 workflow 中断, 不会用新镜像顶掉仍在运行的旧容器 # 每次主分支提交生成 OTA 升级包, sequence 默认时间戳 (单调递增)
env: # 发布说明 = 触发构建的 commit message (标题+正文; 打包脚本内安全截断 400 字符),
DATABASE_URL: ${{ secrets.DATABASE_URL }} # commit sha 通过 --commit 单独写入 manifest, 前端分行展示
run: | # 产物 ota-package/ (tar.gz + latest.json + .minisig) 进入 docker build context, 由 Dockerfile COPY 进镜像
set -euo pipefail env:
npx prisma migrate status || true MINISIGN_PRIVATE_KEY: ${{ secrets.MINISIGN_PRIVATE_KEY }}
npx prisma migrate deploy run: |
set -euo pipefail
- name: Remove old container # 不用 head -c 截断: 那会把多字节汉字切成非法 UTF-8, latest.json 解析失败
run: docker rm -vf iboard || true MSG="$(git log -1 --pretty=format:%B | sed -e '/^[[:space:]]*$/d')"
echo "notes: ${MSG}"
- name: Recycle container node scripts/build-ota.mjs --out ota-package --notes "${MSG}" --commit "${GITHUB_SHA}" --min-binary ${OTA_MIN_BINARY}
run: docker system prune --volumes -f echo "===== latest.json ====="
cat ota-package/latest.json
- name: Run container echo "===== bundle sha256 ====="
# DATABASE_URL 通过 gitea Actions secret 注入, 不进镜像层 sha256sum ota-package/iboard-ota.tar.gz
# OTA_PACKAGE_DIR 已在 Dockerfile 设为 /app/ota-package
# 米家凭证 (auth.txt) 不进 git: 运维放宿主机 /app/iboard/cache, bind-mount 进容器 (MIHOME_CACHE_DIR=/app/cache) - name: Build Docker image
run: | # DATABASE_URL 仅在 prisma generate 阶段需要, 可用任何合法占位串
docker run -d --name iboard -p 3001:3001 \ # PREBUILT_DIST=true: 复用上一步构建好的 dist/, 镜像内跳过前端构建
-e DATABASE_URL="${{ secrets.DATABASE_URL }}" \ # DOCKER_BUILDKIT=1: Dockerfile 用了 cache mount 语法
-e JWT_SECRET="${{ secrets.JWT_SECRET }}" \ run: docker build -t iboard:latest --build-arg DATABASE_URL=mysql://build:build@127.0.0.1:3306/build --build-arg PREBUILT_DIST=true .
-e MIHOME_CACHE_DIR=/app/cache \ env:
-v /app/iboard/cache:/app/cache \ DOCKER_BUILDKIT: '1'
iboard:latest
env: - name: List images
DATABASE_URL: ${{ secrets.DATABASE_URL }} run: docker images
JWT_SECRET: ${{ secrets.JWT_SECRET }}
- name: Apply DB migrations
# 数据库自动升级: 按 prisma/migrations/ 下的迁移顺序应用 (生产库由 master 部署时统一升级)
# migrate deploy 幂等, 无新迁移时为 no-op
# 位置关键: 在 "Remove old container" 之前 —— 建表/改表成功后才切换流量,
# 迁移失败则整个 workflow 中断, 不会用新镜像顶掉仍在运行的旧容器
env:
DATABASE_URL: ${{ secrets.DATABASE_URL }}
run: |
set -euo pipefail
npx prisma migrate status || true
npx prisma migrate deploy
- name: Remove old container
run: docker rm -vf iboard || true
- name: Prune dangling images
# 只清理本项目的悬空镜像 (旧 iboard:latest 被同名新镜像顶掉后变 dangling),
# 不再用 `docker system prune --volumes` —— 那会清光宿主机 build cache,
# 导致每次部署都全量重装 npm 依赖、重跑构建
run: docker image prune -f
- name: Run container
# DATABASE_URL 通过 gitea Actions secret 注入, 不进镜像层
# OTA_PACKAGE_DIR 已在 Dockerfile 设为 /app/ota-package
# 米家凭证 (auth.txt) 不进 git: 运维放宿主机 /app/iboard/cache, bind-mount 进容器 (MIHOME_CACHE_DIR=/app/cache)
run: |
docker run -d --name iboard -p 3001:3001 \
-e DATABASE_URL="${{ secrets.DATABASE_URL }}" \
-e JWT_SECRET="${{ secrets.JWT_SECRET }}" \
-e MIHOME_CACHE_DIR=/app/cache \
-v /app/iboard/cache:/app/cache \
iboard:latest
env:
DATABASE_URL: ${{ secrets.DATABASE_URL }}
JWT_SECRET: ${{ secrets.JWT_SECRET }}
+97 -75
View File
@@ -1,75 +1,97 @@
name: OTA Frontend Build & Sign name: OTA Frontend Build & Sign
# 前端 OTA 升级包构建 + 签名 + 上传 artifact # 前端 OTA 升级包构建 + 签名 + 上传 artifact
# 触发: 仅手动 (workflow_dispatch), 发版前用 # 触发: 仅手动 (workflow_dispatch), 发版前用
# - 主分支 push 的自动 OTA 生成已并入 docker-build.yml (随镜像发布), 本 workflow 不再监听 push 避免重复构建 # - 主分支 push 的自动 OTA 生成已并入 docker-build.yml (随镜像发布), 本 workflow 不再监听 push 避免重复构建
# 输入: sequence / notes / min_binary (可覆盖默认) # 输入: sequence / notes / min_binary (可覆盖默认)
# Secret 依赖: MINISIGN_PRIVATE_KEY (整段 .key 文件内容, 含首行 untrusted comment) # Secret 依赖: MINISIGN_PRIVATE_KEY (整段 .key 文件内容, 含首行 untrusted comment)
# 注意: 此 workflow 不会修改 src-tauri/tauri.conf.json 里的 pubkey, # 注意: 此 workflow 不会修改 src-tauri/tauri.conf.json 里的 pubkey,
# 换密钥请手动改 conf 并 commit. # 换密钥请手动改 conf 并 commit.
on: on:
workflow_dispatch: workflow_dispatch:
inputs: inputs:
sequence: sequence:
description: 'OTA sequence (单调递增整数, 留空用 timestamp)' description: 'OTA sequence (单调递增整数, 留空用 timestamp)'
required: false required: false
type: string type: string
notes: notes:
description: '发布说明 (写入 latest.json.notes)' description: '发布说明 (写入 latest.json.notes)'
required: false required: false
type: string type: string
default: '' default: ''
min_binary: min_binary:
description: '最低 binary 版本 (默认 0.1.0 = 现有 APK 的原生版本, 与前端应用版本无关)' description: '最低 binary 版本 (默认 0.1.0 = 现有 APK 的原生版本, 与前端应用版本无关)'
required: false required: false
type: string type: string
default: '0.1.0' default: '0.1.0'
jobs: jobs:
build-ota: build-ota:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout - name: Checkout
uses: https://gitea.com/actions/checkout@v4 uses: https://gitea.com/actions/checkout@v4
- name: Setup Node - name: Setup Node
uses: https://gitea.com/actions/setup-node@v4 uses: https://gitea.com/actions/setup-node@v4
with: with:
node-version: 22 node-version: 22
- name: Install deps - name: Install deps
run: npm ci --no-audit --no-fund run: npm ci --no-audit --no-fund
- name: Build frontend - name: Build frontend
# 复用 vite build 产物; 不打 Docker (本 workflow 只关心前端) # 复用 vite build 产物; 不打 Docker (本 workflow 只关心前端)
run: npm run build run: npm run build
- name: Install minisign - name: Install minisign
run: sudo apt-get update && sudo apt-get install -y minisign # 静态二进制直接下载, 免去每次 apt-get update + install
# 注意: release tarball 是 macOS 上打包的, 混有多个平台的 minisign 二进制,
- name: Build & sign OTA package # 逐个试运行挑出本机可执行的, 都不行再回退 apt
env: run: |
MINISIGN_PRIVATE_KEY: ${{ secrets.MINISIGN_PRIVATE_KEY }} set -euo pipefail
OTA_NOTES: ${{ inputs.notes }} mkdir -p /tmp/ms
run: | curl -sL https://github.com/jedisct1/minisign/releases/download/0.12/minisign-0.12-linux.tar.gz | tar xz -C /tmp/ms
set -euo pipefail BIN=""
OUT="ota-package" for c in $(find /tmp/ms -type f -name minisign); do
ARGS="--out ${OUT}" if "$c" -v >/dev/null 2>&1; then BIN="$c"; break; fi
# sequence 留空 → 脚本默认用时间戳; min_binary 默认 0.1.0 (与前端版本解耦) done
if [ -n "${{ inputs.sequence }}" ]; then ARGS="${ARGS} --sequence ${{ inputs.sequence }}"; fi if [ -n "$BIN" ]; then
if [ -n "${OTA_NOTES}" ]; then ARGS="${ARGS} --notes ${OTA_NOTES}"; fi sudo install -m755 "$BIN" /usr/local/bin/minisign
if [ -n "${{ inputs.min_binary }}" ]; then ARGS="${ARGS} --min-binary ${{ inputs.min_binary }}"; fi else
node scripts/build-ota.mjs ${ARGS} echo "未找到可执行的静态二进制, 回退 apt 安装"
echo "===== latest.json =====" sudo apt-get update && sudo apt-get install -y minisign
cat ${OUT}/latest.json fi
echo "===== bundle sha256 =====" minisign -v
sha256sum ${OUT}/iboard-ota.tar.gz
- name: Build & sign OTA package
- name: Upload OTA package as artifact # 发布说明默认 = 检出 commit 的 message (标题+正文), commit sha 单独写入;
uses: https://gitea.com/actions/upload-artifact@v4 # 手动填写的 notes 优先
with: env:
name: iboard-ota-${{ inputs.sequence || github.run_number }} MINISIGN_PRIVATE_KEY: ${{ secrets.MINISIGN_PRIVATE_KEY }}
path: ota-package/ run: |
retention-days: 30 set -euo pipefail
if-no-files-found: error OUT="ota-package"
ARGS="--out ${OUT} --commit ${GITHUB_SHA}"
if [ -n "${{ inputs.sequence }}" ]; then ARGS="${ARGS} --sequence ${{ inputs.sequence }}"; fi
if [ -n "${{ inputs.notes }}" ]; then
ARGS="${ARGS} --notes \"${{ inputs.notes }}\""
else
MSG="$(git log -1 --pretty=format:%B | sed -e '/^[[:space:]]*$/d')"
ARGS="${ARGS} --notes \"${MSG}\""
fi
if [ -n "${{ inputs.min_binary }}" ]; then ARGS="${ARGS} --min-binary ${{ inputs.min_binary }}"; fi
eval "node scripts/build-ota.mjs ${ARGS}"
echo "===== latest.json ====="
cat ${OUT}/latest.json
echo "===== bundle sha256 ====="
sha256sum ${OUT}/iboard-ota.tar.gz
- name: Upload OTA package as artifact
uses: https://gitea.com/actions/upload-artifact@v4
with:
name: iboard-ota-${{ inputs.sequence || github.run_number }}
path: ota-package/
retention-days: 30
if-no-files-found: error
+78
View File
@@ -0,0 +1,78 @@
// CDP 触摸手势驱动: 长按 400ms 激活 + 水平拖动, 观察 useRowDrag 行为
import WebSocket from 'ws'
import { spawn } from 'node:child_process'
const C = JSON.parse((await (await fetch('http://127.0.0.1:9223/json/version')).text()))
const ws = new WebSocket(C.webSocketDebuggerUrl, { perMessageDeflate: false })
let id = 0
const pending = new Map()
const send = (method, params = {}, sessionId) => new Promise((res) => {
const i = ++id
pending.set(i, res)
ws.send(JSON.stringify({ id: i, method, params, sessionId }))
})
ws.on('message', (d) => {
const m = JSON.parse(d)
if (m.id && pending.has(m.id)) { pending.get(m.id)(m.result); pending.delete(m.id) }
if (m.method === 'Runtime.consoleAPICalled') {
console.log('[console]', m.params.type, m.params.args.map((a) => a.value ?? a.description).join(' '))
}
})
await new Promise((r) => ws.on('open', r))
const { targetId } = await send('Target.createTarget', { url: 'about:blank' })
const { sessionId } = await send('Target.attachToTarget', { targetId, flatten: true })
const S = (m, p) => send(m, p, sessionId)
await S('Runtime.enable')
await S('Page.enable')
await S('Emulation.setDeviceMetricsOverride', { width: 400, height: 800, deviceScaleFactor: 2, mobile: true })
await S('Emulation.setTouchEmulationEnabled', { enabled: true, maxTouchPoints: 5 })
await S('Page.navigate', { url: 'http://127.0.0.1:5199/dragtest.html' })
await new Promise((r) => setTimeout(r, 3000))
const sleep = (ms) => new Promise((res) => setTimeout(res, ms))
const ev = async (expr) => (await S('Runtime.evaluate', { expression: expr, returnByValue: true })).result.value
let rect = null
for (let i = 0; i < 30 && !rect; i++) {
await sleep(1000)
const v = await ev(`(() => { const el = document.querySelector('[data-task-id="3"]'); return el ? JSON.stringify(el.getBoundingClientRect()) : null })()`)
if (v) rect = JSON.parse(v)
}
if (!rect) { console.log('page never loaded'); ws.close(); process.exit(1) }
console.log('row rect', JSON.stringify(rect))
const r = rect
const x0 = r.left + r.width / 2
const y0 = r.top + r.height / 2
const touch = (type, x, y) => S('Input.dispatchTouchEvent', {
type,
touchPoints: type === 'touchEnd' ? [] : [{ x, y, id: 1 }],
})
// 1) touchStart + 按住 500ms (激活长按)
await touch('touchStart', x0, y0)
console.log('--- touchStart, holding 500ms')
await sleep(500)
// 2) 水平拖动 +200px, 每步 +25px / 40ms (纯水平)
for (let dx = 25; dx <= 200; dx += 25) {
await touch('touchMove', x0 + dx, y0)
await sleep(40)
}
console.log('--- moved to +200px (pure horizontal)')
// 3) 带纵向分量的拖动 (模拟真实手指): +160px 横 / +40px 纵
await touch('touchMove', x0 + 60, y0 + 12)
await sleep(40)
await touch('touchMove', x0 + 120, y0 + 26)
await sleep(40)
await touch('touchMove', x0 + 160, y0 + 40)
await sleep(40)
await touch('touchEnd')
await sleep(600)
console.log('--- events on page:')
console.log((await ev('window.__ev.join("\\n")')))
ws.close()
+77
View File
@@ -0,0 +1,77 @@
// CDP 触摸手势驱动 v2: 把手立即拖动 + 长按路径, 组内/跨组落点
import WebSocket from 'ws'
const C = JSON.parse((await (await fetch('http://127.0.0.1:9223/json/version')).text()))
const ws = new WebSocket(C.webSocketDebuggerUrl, { perMessageDeflate: false })
let id = 0
const pending = new Map()
const send = (method, params = {}, sessionId) => new Promise((res) => {
const i = ++id
pending.set(i, res)
ws.send(JSON.stringify({ id: i, method, params, sessionId }))
})
ws.on('message', (d) => {
const m = JSON.parse(d)
if (m.id && pending.has(m.id)) { pending.get(m.id)(m.result); pending.delete(m.id) }
})
await new Promise((r) => ws.on('open', r))
const { targetId } = await send('Target.createTarget', { url: 'about:blank' })
const { sessionId } = await send('Target.attachToTarget', { targetId, flatten: true })
const S = (m, p) => send(m, p, sessionId)
await S('Runtime.enable'); await S('Page.enable')
await S('Emulation.setDeviceMetricsOverride', { width: 400, height: 800, deviceScaleFactor: 2, mobile: true })
await S('Emulation.setTouchEmulationEnabled', { enabled: true, maxTouchPoints: 5 })
await S('Page.navigate', { url: 'http://127.0.0.1:5199/dragtest.html' })
const sleep = (ms) => new Promise((res) => setTimeout(res, ms))
const ev = async (expr) => (await S('Runtime.evaluate', { expression: expr, returnByValue: true })).result.value
const center = async (sel) => {
const r = JSON.parse(await ev(`JSON.stringify(document.querySelector('${sel}').getBoundingClientRect())`))
return { x: r.left + r.width / 2, y: r.top + r.height / 2 }
}
for (let i = 0; i < 30; i++) { await sleep(1000); if (await ev(`!!document.querySelector('[data-habit-id="1"]')`)) break }
const touch = (type, x, y) => S('Input.dispatchTouchEvent', {
type, touchPoints: type === 'touchEnd' ? [] : [{ x, y, id: 1 }],
})
// 用例 1: 按住 habit-1 的把手, 立即垂直拖到 habit-4 中部, 松手 → 应 DROP before/after 4
let p = await center('[data-habit-id="1"] .grip')
await touch('touchStart', p.x, p.y)
await sleep(60)
const t = await center('[data-habit-id="4"]')
await touch('touchMove', p.x, p.y + 20)
await sleep(30)
await touch('touchMove', t.x, t.y)
await sleep(60)
const cls1 = await ev(`document.querySelector('[data-habit-id="4"]').className`)
console.log('case1 target class:', cls1)
await touch('touchEnd')
await sleep(300)
// 用例 2: 按住 habit-1 把手拖到每周组 (habit-5) → 跨组, 应无落点无 DROP
p = await center('[data-habit-id="1"] .grip')
await touch('touchStart', p.x, p.y)
await sleep(60)
const w = await center('[data-habit-id="5"]')
await touch('touchMove', w.x, w.y)
await sleep(60)
const cls2 = await ev(`document.querySelector('[data-habit-id="5"]').className`)
console.log('case2 target class (应无 dragover):', cls2)
await touch('touchEnd')
await sleep(300)
// 用例 3: 长按卡片主体 500ms 后拖到 habit-3 下半 → DROP after 3
p = await center('[data-habit-id="2"]')
await touch('touchStart', p.x, p.y)
await sleep(520)
const t3 = await center('[data-habit-id="3"]')
await touch('touchMove', t3.x, t3.y + 20)
await sleep(60)
await touch('touchEnd')
await sleep(300)
console.log('--- events:')
console.log(await ev('window.__ev.join("\\n")'))
ws.close()
+27
View File
@@ -0,0 +1,27 @@
import WebSocket from 'ws'
const C = JSON.parse((await (await fetch('http://127.0.0.1:9223/json/version')).text()))
const ws = new WebSocket(C.webSocketDebuggerUrl, { perMessageDeflate: false })
let id = 0; const pending = new Map()
const send = (m, p = {}, s) => new Promise((r) => { const i = ++id; pending.set(i, r); ws.send(JSON.stringify({ id: i, method: m, params: p, sessionId: s })) })
ws.on('message', (d) => { const m = JSON.parse(d); if (m.id && pending.has(m.id)) { pending.get(m.id)(m.result); pending.delete(m.id) } })
await new Promise((r) => ws.on('open', r))
const targets = await send('Target.getTargets')
const t = targets.targetInfos.find((x) => x.url.includes('dragtest'))
const { sessionId } = await send('Target.attachToTarget', { targetId: t.targetId, flatten: true })
const S = (m, p) => send(m, p, sessionId)
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
const ev = async (e) => (await S('Runtime.evaluate', { expression: e, returnByValue: true })).result.value
// 长按 habit-2 主体, 分别在 450ms 后检查激活态, 再拖动
const r = JSON.parse(await ev(`JSON.stringify(document.querySelector('[data-habit-id="2"]').getBoundingClientRect())`))
const x = r.left + r.width / 2, y = r.top + r.height / 2
await S('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [{ x, y, id: 1 }] })
await sleep(460)
console.log('after 460ms class:', await ev(`document.querySelector('[data-habit-id="2"]').className`))
const r3 = JSON.parse(await ev(`JSON.stringify(document.querySelector('[data-habit-id="3"]').getBoundingClientRect())`))
await S('Input.dispatchTouchEvent', { type: 'touchMove', touchPoints: [{ x: r3.left + r3.width / 2, y: r3.top + r3.height - 8, id: 1 }] })
await sleep(80)
console.log('class of 3 after move:', await ev(`document.querySelector('[data-habit-id="3"]').className`))
await S('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] })
await sleep(300)
console.log('events:', await ev('window.__ev.join(" | ")'))
ws.close()
+32
View File
@@ -0,0 +1,32 @@
import WebSocket from 'ws'
const C = JSON.parse((await (await fetch('http://127.0.0.1:9223/json/version')).text()))
const ws = new WebSocket(C.webSocketDebuggerUrl, { perMessageDeflate: false })
let id = 0; const pending = new Map()
const send = (m, p = {}, s) => new Promise((r) => { const i = ++id; pending.set(i, r); ws.send(JSON.stringify({ id: i, method: m, params: p, sessionId: s })) })
ws.on('message', (d) => { const m = JSON.parse(d); if (m.id && pending.has(m.id)) { pending.get(m.id)(m.result); pending.delete(m.id) } })
await new Promise((r) => ws.on('open', r))
const { targetId } = await send('Target.createTarget', { url: 'about:blank' })
const { sessionId } = await send('Target.attachToTarget', { targetId, flatten: true })
const S = (m, p) => send(m, p, sessionId)
await S('Runtime.enable'); await S('Page.enable')
await S('Emulation.setDeviceMetricsOverride', { width: 400, height: 800, deviceScaleFactor: 2, mobile: true })
await S('Emulation.setTouchEmulationEnabled', { enabled: true, maxTouchPoints: 5 })
await S('Page.navigate', { url: 'http://127.0.0.1:5199/dragtest.html' })
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
const ev = async (e) => (await S('Runtime.evaluate', { expression: e, returnByValue: true })).result.value
for (let i = 0; i < 30; i++) { await sleep(1000); if (await ev(`!!document.querySelector('[data-task-id="101"]')`)) break }
const touch = (type, x, y) => S('Input.dispatchTouchEvent', { type, touchPoints: type === 'touchEnd' ? [] : [{ x, y, id: 1 }] })
// 用例1: 任务B(102) 把手拖到任务D(104) 下半 → 期望 REORDER 102 after 104
const g = JSON.parse(await ev(`JSON.stringify(document.querySelector('[data-task-id="102"] .grip').getBoundingClientRect())`))
await touch('touchStart', g.left + g.width / 2, g.top + g.height / 2)
await sleep(60)
const d = JSON.parse(await ev(`JSON.stringify(document.querySelector('[data-task-id="104"]').getBoundingClientRect())`))
await touch('touchMove', d.left + d.width / 2, d.top + d.height - 10)
await sleep(120)
console.log('row104 class:', await ev(`document.querySelector('[data-task-id="104"]').className`))
console.log('drag state:', await ev(`JSON.stringify({drag: !!document.querySelector('.task-row.dragging'), ghost: !!document.querySelector('.drag-ghost')})`))
await touch('touchEnd')
await sleep(200)
console.log('events:', await ev('window.__ev.join(" | ")'))
ws.close()
+40
View File
@@ -0,0 +1,40 @@
import WebSocket from 'ws'
const C = JSON.parse((await (await fetch('http://127.0.0.1:9223/json/version')).text()))
const ws = new WebSocket(C.webSocketDebuggerUrl, { perMessageDeflate: false })
let id = 0; const pending = new Map()
const send = (m, p = {}, s) => new Promise((r) => { const i = ++id; pending.set(i, r); ws.send(JSON.stringify({ id: i, method: m, params: p, sessionId: s })) })
ws.on('message', (d) => { const m = JSON.parse(d); if (m.id && pending.has(m.id)) { pending.get(m.id)(m.result); pending.delete(m.id) } })
await new Promise((r) => ws.on('open', r))
const targets = await send('Target.getTargets')
const t = targets.targetInfos.find((x) => x.url.includes('dragtest'))
const { sessionId } = await send('Target.attachToTarget', { targetId: t.targetId, flatten: true })
const S = (m, p) => send(m, p, sessionId)
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
const ev = async (e) => (await S('Runtime.evaluate', { expression: e, returnByValue: true })).result.value
const touch = (type, x, y) => S('Input.dispatchTouchEvent', { type, touchPoints: type === 'touchEnd' ? [] : [{ x, y, id: 1 }] })
const r = JSON.parse(await ev(`JSON.stringify(document.querySelector('[data-habit-id="2"] .grip').getBoundingClientRect())`))
const x = r.left + r.width / 2, y = r.top + r.height / 2
await touch('touchStart', x, y)
await sleep(60)
const t4 = JSON.parse(await ev(`JSON.stringify(document.querySelector('[data-habit-id="4"]').getBoundingClientRect())`))
const fx = t4.left + t4.width / 2, fy = t4.top + t4.height / 2
await touch('touchMove', fx, fy)
await sleep(150)
const scan = await ev(`(() => {
const out = []
document.querySelectorAll('*').forEach((el) => {
const cs = getComputedStyle(el)
const b = el.getBoundingClientRect()
if (b.width === 0 || b.height === 0) return
const bg = cs.backgroundColor
// 找带蓝色背景的元素
if (/rgb\\((22|2[0-9]|1[0-9]), ?1[0-9]{2}/.test(bg) || bg.includes('22, 119, 255') || bg.includes('0, 149, 255')) {
out.push({ tag: el.tagName, cls: el.className && String(el.className).slice(0, 40), bg, rect: [Math.round(b.left), Math.round(b.top), Math.round(b.width), Math.round(b.height)], text: (el.textContent || '').slice(0, 12) })
}
})
return JSON.stringify(out)
})()`)
console.log('blue elements:', scan)
console.log('ghost children:', await ev(`(() => { const g = document.querySelector('.drag-ghost'); return g ? g.children.length + ' kids, inner w ' + g.getBoundingClientRect().width : 'none' })()`))
await touch('touchEnd')
ws.close()
+35
View File
@@ -0,0 +1,35 @@
import WebSocket from 'ws'
import fs from 'node:fs'
const C = JSON.parse((await (await fetch('http://127.0.0.1:9223/json/version')).text()))
const ws = new WebSocket(C.webSocketDebuggerUrl, { perMessageDeflate: false })
let id = 0; const pending = new Map()
const send = (m, p = {}, s) => new Promise((r) => { const i = ++id; pending.set(i, r); ws.send(JSON.stringify({ id: i, method: m, params: p, sessionId: s })) })
ws.on('message', (d) => { const m = JSON.parse(d); if (m.id && pending.has(m.id)) { pending.get(m.id)(m.result); pending.delete(m.id) } })
await new Promise((r) => ws.on('open', r))
const { targetId } = await send('Target.createTarget', { url: 'about:blank' })
const { sessionId } = await send('Target.attachToTarget', { targetId, flatten: true })
const S = (m, p) => send(m, p, sessionId)
await S('Runtime.enable'); await S('Page.enable')
await S('Emulation.setDeviceMetricsOverride', { width: 400, height: 800, deviceScaleFactor: 2, mobile: true })
await S('Emulation.setTouchEmulationEnabled', { enabled: true, maxTouchPoints: 5 })
await S('Page.navigate', { url: 'http://127.0.0.1:5199/dragtest.html' })
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
const ev = async (e) => (await S('Runtime.evaluate', { expression: e, returnByValue: true })).result.value
for (let i = 0; i < 30; i++) { await sleep(1000); if (await ev(`!!document.querySelector('[data-habit-id="1"]')`)) break }
const touch = (type, x, y) => S('Input.dispatchTouchEvent', { type, touchPoints: type === 'touchEnd' ? [] : [{ x, y, id: 1 }] })
const r = JSON.parse(await ev(`JSON.stringify(document.querySelector('[data-habit-id="2"] .grip').getBoundingClientRect())`))
const x = r.left + r.width / 2, y = r.top + r.height / 2
await touch('touchStart', x, y)
await sleep(60)
const t = JSON.parse(await ev(`JSON.stringify(document.querySelector('[data-habit-id="4"]').getBoundingClientRect())`))
await touch('touchMove', t.x ?? t.left + t.width / 2, t.top + t.height / 2)
await sleep(120)
// 拖动中截图
const shot = await S('Page.captureScreenshot', { format: 'png' })
fs.writeFileSync(process.env.USERPROFILE + '\\AppData\\Local\\Temp\\drag-mid.png', Buffer.from(shot.data, 'base64'))
console.log('shot saved; ghost exists:', await ev(`!!document.querySelector('.drag-ghost')`))
console.log('ghost html head:', (await ev(`document.querySelector('.drag-ghost') ? document.querySelector('.drag-ghost').outerHTML.slice(0, 200) : 'NONE'`)))
await touch('touchEnd')
await sleep(200)
console.log('events:', await ev('window.__ev.join(" | ")'))
ws.close()
+36
View File
@@ -0,0 +1,36 @@
import WebSocket from 'ws'
const C = JSON.parse((await (await fetch('http://127.0.0.1:9223/json/version')).text()))
const ws = new WebSocket(C.webSocketDebuggerUrl, { perMessageDeflate: false })
let id = 0; const pending = new Map()
const send = (m, p = {}, s) => new Promise((r) => { const i = ++id; pending.set(i, r); ws.send(JSON.stringify({ id: i, method: m, params: p, sessionId: s })) })
ws.on('message', (d) => { const m = JSON.parse(d); if (m.id && pending.has(m.id)) { pending.get(m.id)(m.result); pending.delete(m.id) } })
await new Promise((r) => ws.on('open', r))
const { targetId } = await send('Target.createTarget', { url: 'about:blank' })
const { sessionId } = await send('Target.attachToTarget', { targetId, flatten: true })
const S = (m, p) => send(m, p, sessionId)
await S('Runtime.enable'); await S('Page.enable')
await S('Emulation.setDeviceMetricsOverride', { width: 400, height: 800, deviceScaleFactor: 2, mobile: true })
await S('Emulation.setTouchEmulationEnabled', { enabled: true, maxTouchPoints: 5 })
await S('Page.navigate', { url: 'http://127.0.0.1:5199/dragtest.html' })
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
const ev = async (e) => (await S('Runtime.evaluate', { expression: e, returnByValue: true })).result.value
for (let i = 0; i < 30; i++) { await sleep(1000); if (await ev(`!!document.querySelector('[data-task-id="3"]')`)) break }
await sleep(1000) // 等延迟数据加载完
const touch = (type, x, y) => S('Input.dispatchTouchEvent', { type, touchPoints: type === 'touchEnd' ? [] : [{ x, y, id: 1 }] })
// 用例: 按住任务3的把手 → 水平拖 +200px (带 +30 纵向漂移, 复现真机抖动) → 松手
const r = JSON.parse(await ev(`JSON.stringify(document.querySelector('[data-task-id="3"] .grip').getBoundingClientRect())`))
let x = r.left + r.width / 2, y = r.top + r.height / 2
console.log('grip rect', JSON.stringify(r))
await touch('touchStart', x, y)
await sleep(60)
for (const [dx, dy] of [[25, 4], [60, 10], [110, 18], [160, 26], [200, 30]]) {
await touch('touchMove', x + dx, y + dy)
await sleep(50)
}
console.log('row class after moves:', await ev(`document.querySelector('[data-task-id="3"]').className`))
console.log('meta:', await ev(`document.querySelector('[data-task-id="3"] .task-meta').textContent`))
await touch('touchEnd')
await sleep(300)
console.log('events:', await ev('window.__ev.join(" | ")'))
ws.close()
+30
View File
@@ -0,0 +1,30 @@
import WebSocket from 'ws'
import fs from 'node:fs'
const C = JSON.parse((await (await fetch('http://127.0.0.1:9223/json/version')).text()))
const ws = new WebSocket(C.webSocketDebuggerUrl, { perMessageDeflate: false })
let id = 0; const pending = new Map()
const send = (m, p = {}, s) => new Promise((r) => { const i = ++id; pending.set(i, r); ws.send(JSON.stringify({ id: i, method: m, params: p, sessionId: s })) })
ws.on('message', (d) => { const m = JSON.parse(d); if (m.id && pending.has(m.id)) { pending.get(m.id)(m.result); pending.delete(m.id) } })
await new Promise((r) => ws.on('open', r))
const targets = await send('Target.getTargets')
const t = targets.targetInfos.find((x) => x.url.includes('dragtest'))
const { sessionId } = await send('Target.attachToTarget', { targetId: t.targetId, flatten: true })
const S = (m, p) => send(m, p, sessionId)
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
const ev = async (e) => (await S('Runtime.evaluate', { expression: e, returnByValue: true })).result.value
await S('Page.reload')
await sleep(2500)
const touch = (type, x, y) => S('Input.dispatchTouchEvent', { type, touchPoints: type === 'touchEnd' ? [] : [{ x, y, id: 1 }] })
const r = JSON.parse(await ev(`JSON.stringify(document.querySelector('[data-habit-id="2"] .grip').getBoundingClientRect())`))
const x = r.left + r.width / 2, y = r.top + r.height / 2
await touch('touchStart', x, y)
await sleep(60)
const t4 = JSON.parse(await ev(`JSON.stringify(document.querySelector('[data-habit-id="4"]').getBoundingClientRect())`))
await touch('touchMove', t4.left + t4.width / 2, t4.top + t4.height / 2)
await sleep(150)
console.log('ghost bg:', await ev(`getComputedStyle(document.querySelector('.drag-ghost')).backgroundColor`))
console.log('ghost rect:', await ev(`JSON.stringify(document.querySelector('.drag-ghost').getBoundingClientRect())`))
const shot = await S('Page.captureScreenshot', { format: 'png' })
fs.writeFileSync(process.env.USERPROFILE + '\\AppData\\Local\\Temp\\drag-fixed.png', Buffer.from(shot.data, 'base64'))
await touch('touchEnd')
ws.close()
+8
View File
@@ -0,0 +1,8 @@
fix(V0.2): 拖动浮层残留蓝色背景 —— 类名与遗留 .drag-ghost 规则冲突
globals.css 里有一条无人使用的旧 .drag-ghost 规则 (蓝色气泡,
max-width 180px, 看板早期方案遗留), 排在新增规则之后同特异性胜出,
克隆卡片容器被套上蓝色背景并被压缩到 180px 宽, 即真机上手下面的
蓝色 div。删除遗留规则; 新规则显式清零背景/内边距并禁用
tap-highlight/user-select (克隆脱离 .mv-view/.task-content 祖先后
需自带防选中)。CDP 实测拖动中容器背景已透明、宽度正常。
+4
View File
@@ -0,0 +1,4 @@
const s = '问题,;、!'
console.log(JSON.stringify(s.replace(/[\s\p{P}]+$/u, '')))
console.log(JSON.stringify('正常标题'.replace(/[\s\p{P}]+$/u, '')))
console.log(JSON.stringify('a-b_c'.replace(/[\s\p{P}]+$/u, '')))
@@ -0,0 +1,5 @@
Exception during registration:
System.ComponentModel.Win32Exception (0x80004005): 系统找不到指定的文件。
在 System.Diagnostics.Process.StartWithCreateProcess(ProcessStartInfo startInfo)
在 System.CommandLine.Invocation.Process.StartProcess(String command, String args, String workingDir, Action`1 stdOut, Action`1 stdErr, ValueTuple`2[] environmentVariables)
在 System.CommandLine.Builder.CommandLineBuilderExtensions.<>c.<<RegisterWithDotnetSuggest>b__5_1>d.MoveNext()
+31
View File
@@ -0,0 +1,31 @@
# CHANGELOG
版本号规则见 [README.md](README.md)「版本管理」。前端与外壳版本号**互相独立**、独立发版,
分三部分记录;PATCH 仅在明确提出时自增, 否则沿用, 每次变更在对应部分追加一行。
---
## 程序版本 (Web / OTA)
**当前值: 0.2.0** (管理点: `package.json` 的 `version`; OTA 包 = 同值 + `-ota`)
| 日期 | 版本 | commit | 要点 |
| --- | --- | --- | --- |
| 2026-10-25 | 0.2.0 | — | V0.2 基线。前端与外壳版本解绑; build 标识统一 `YYYYMMDDHHmmss` (北京时间); PATCH 规则确立 (仅明确提出时自增) |
| 2026-10-25 | 0.2.1 | 93b7c7b | 主要解决离线编辑与不同终端数据合并问题 |
## Android 外壳版本
**当前值: 0.2.0** (管理点: `src-tauri/tauri.conf.json` 的 `version`, 须与 Cargo.toml/Cargo.lock 组内一致; 产物 = APK versionName)
| 日期 | 版本 | commit | 要点 |
| --- | --- | --- | --- |
| 2026-10-25 | 0.2.0 | — | V0.2 基线。外壳与前端版本解绑, 独立维护 (android-* 分支出包) |
## Windows 外壳版本
**当前值: —** (尚未建立; 建立后管理点同为 `src-tauri/tauri.conf.json` + Cargo 三件套, 走独立分支/打包工作流)
| 日期 | 版本 | commit | 要点 |
| --- | --- | --- | --- |
| — | — | — | 暂无 |
+84 -72
View File
@@ -1,73 +1,85 @@
# =============================================================== # syntax=docker/dockerfile:1
# iBoard 一体化镜像: 静态前端 (dist/) + Express API (server/) 同进程 # ===============================================================
# 构建: docker build --build-arg DATABASE_URL=... -t iboard:latest . # iBoard 一体化镜像: 静态前端 (dist/) + Express API (server/) 同进程
# 可选 --build-arg VITE_API_BASE=https://api.example.com 让前端走绝对地址 # 构建: docker build --build-arg DATABASE_URL=... -t iboard:latest .
# 运行: docker run -d --name iboard -p 3001:3001 -e DATABASE_URL=... iboard:latest # 可选 --build-arg VITE_API_BASE=https://api.example.com 让前端走绝对地址
# =============================================================== # CI 传入 --build-arg PREBUILT_DIST=true 复用 CI 已构建的 dist/ (跳过镜像内前端构建)
FROM node:22-alpine AS builder # 运行: docker run -d --name iboard -p 3001:3001 -e DATABASE_URL=... iboard:latest
# 需要 BuildKit (cache mount 语法; Docker 23+ 默认开启)
WORKDIR /app # ===============================================================
FROM node:22-alpine AS builder
# 国内构建时可换源
RUN npm config set registry https://registry.npmmirror.com WORKDIR /app
# 先拷贝 package 文件, 利用 Docker 缓存 # 国内构建时可换源
COPY package*.json ./ RUN npm config set registry https://registry.npmmirror.com
COPY prisma ./prisma/
COPY prisma.config.ts ./ # 先拷贝 package 文件, 利用 Docker 缓存
COPY generated ./generated/ COPY package*.json ./
COPY prisma ./prisma/
# 装全量依赖 (dev 也装, 后面 build 要用) COPY prisma.config.ts ./
RUN npm install COPY generated ./generated/
# 拷贝源码 # 装全量依赖 (dev 也装, 后面 prisma generate / 本地构建前端要用)
COPY . . # cache mount 复用 npm 下载缓存, 不进镜像层
RUN --mount=type=cache,target=/root/.npm npm ci
# 构建前端 (输出到 dist/) 与 Prisma 客户端
RUN npx prisma generate # 拷贝源码
# build 时把 VITE_API_BASE 写进 bundle, 默认空 = 走相对路径 /api (同源 Express) COPY . .
ARG VITE_API_BASE=
ENV VITE_API_BASE=$VITE_API_BASE # 构建 Prisma 客户端
RUN npm run build RUN --mount=type=cache,target=/root/.npm npx prisma generate
# 运行时阶段 # 构建前端 (输出到 dist/)
FROM node:22-alpine # PREBUILT_DIST=true: CI 已在镜像外构建过 dist/ (供 OTA 打包), 直接复用, 跳过镜像内构建
ARG PREBUILT_DIST=false
WORKDIR /app # build 时把 VITE_API_BASE 写进 bundle, 默认空 = 走相对路径 /api (同源 Express)
ARG VITE_API_BASE=
# 阿里源加速 ENV VITE_API_BASE=$VITE_API_BASE
RUN npm config set registry https://registry.npmmirror.com RUN if [ "$PREBUILT_DIST" = "true" ]; then \
test -f dist/index.html || { echo "PREBUILT_DIST=true 但 dist/ 缺失"; exit 1; }; \
# 只装运行时依赖 else \
COPY package*.json ./ npm run build; \
RUN npm install --omit=dev fi
# 拷贝构建产物 # 运行时阶段
COPY --from=builder /app/dist ./dist FROM node:22-alpine
COPY --from=builder /app/server ./server
COPY --from=builder /app/generated ./generated WORKDIR /app
COPY --from=builder /app/prisma ./prisma
COPY --from=builder /app/prisma.config.ts ./prisma.config.ts # 阿里源加速
COPY --from=builder /app/scripts ./scripts RUN npm config set registry https://registry.npmmirror.com
COPY --from=builder /app/content ./content
COPY --from=builder /app/public ./public # 只装运行时依赖
COPY package*.json ./
# OTA 升级包: CI (docker-build.yml) 生成的 ota-package/, 随镜像发布 RUN --mount=type=cache,target=/root/.npm npm ci --omit=dev
# 本地构建时只有占位文件 (ota-package/README.md), server 找不到 latest.json 则 OTA 返回"无更新"
COPY ota-package ./ota-package # 拷贝构建产物
ENV OTA_PACKAGE_DIR=/app/ota-package COPY --from=builder /app/dist ./dist
# OTA bundle 下载用的绝对 base (manifest.url 必须是绝对 URL, 插件不拼 base, 相对路径会导致下载 0% 失败) COPY --from=builder /app/server ./server
# 生产为 http 明文 (无 https), 客户端 tauri.conf.json 的 hotswap.require_https 已设为 false 放行 http COPY --from=builder /app/generated ./generated
ENV OTA_PUBLIC_BASE=http://bh.vps.honor3.com:3001 COPY --from=builder /app/prisma ./prisma
COPY --from=builder /app/prisma.config.ts ./prisma.config.ts
# 健康检查 COPY --from=builder /app/scripts ./scripts
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ COPY --from=builder /app/content ./content
CMD wget -qO- http://127.0.0.1:3001/api/health || exit 1 COPY --from=builder /app/public ./public
EXPOSE 3001 # OTA 升级包: CI (docker-build.yml) 生成的 ota-package/, 随镜像发布
# 本地构建时只有占位文件 (ota-package/README.md), server 找不到 latest.json 则 OTA 返回"无更新"
ENV NODE_ENV=production COPY ota-package ./ota-package
ENV PORT=3001 ENV OTA_PACKAGE_DIR=/app/ota-package
# OTA bundle 下载用的绝对 base (manifest.url 必须是绝对 URL, 插件不拼 base, 相对路径会导致下载 0% 失败)
# Node 22 默认未开 strip-types, 显式开启以便加载 generated/prisma/*.ts # 生产为 http 明文 (无 https), 客户端 tauri.conf.json 的 hotswap.require_https 已设为 false 放行 http
ENV OTA_PUBLIC_BASE=http://bh.vps.honor3.com:3001
# 健康检查
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget -qO- http://127.0.0.1:3001/api/health || exit 1
EXPOSE 3001
ENV NODE_ENV=production
ENV PORT=3001
# Node 22 默认未开 strip-types, 显式开启以便加载 generated/prisma/*.ts
CMD ["node", "--experimental-strip-types", "--no-warnings", "server/index.js"] CMD ["node", "--experimental-strip-types", "--no-warnings", "server/index.js"]
+101 -88
View File
@@ -1,5 +1,28 @@
# iBoard # iBoard
用于收集和整理经济数据,但是以策略为主体重新组织。
跨平台个人看板 (Tauri + React + Express + MariaDB)。用于收集和整理经济数据,但是以策略为主体重新组织。
- 首页主要是放经济文章
- 数据主要是放各种经济数据和图表
- 策略是放按照经济理论组织的相关数据和图表的对比
## 首页
首页显示新闻模块和报警模块的数据,按照时间最新优先,无限滚动,直到数据库中没有数据为止。
### 新闻模块
增加一个新闻模块,数据来源是每日夜间 3 点,通过妙想 MCP 获取国内外最新经济政治新闻,按影响大小排序 10 条。存入数据库。
### 报警模块
World Bank 中的数据只用来对比妙想 MCP 拉取的数据正确性。当同一时间的同一项数据不同时,记录一个报警。
## 监控
监控分为服务器监控和服务监控两部分。服务器监控对接 komari,地址:<https://komari.honor3.com/>
---
## 分支规则 ## 分支规则
@@ -10,7 +33,7 @@
| `master` | **主分支**(生产分支) | ✅ 唯一部署出口,push 即发布生产 | — | | `master` | **主分支**(生产分支) | ✅ 唯一部署出口,push 即发布生产 | — |
| `dev-*` | **开发集成分支**,如 `dev-0.2` | ❌ 不部署(只跑 CI 校验) | 合到 `master` 后由 master 部署 | | `dev-*` | **开发集成分支**,如 `dev-0.2` | ❌ 不部署(只跑 CI 校验) | 合到 `master` 后由 master 部署 |
| `tmp-*` | **临时分支**,针对单个问题的修改 | ❌ 不部署 | 合到对应 `dev-*` 后删除 | | `tmp-*` | **临时分支**,针对单个问题的修改 | ❌ 不部署 | 合到对应 `dev-*` 后删除 |
| `android-*` | **Android 外壳构建分支**,如 `android-1.0` | ❌ 不部署,只触发 APK 构建 | 构建前须与 `master` 同步 | | `android-*` | **Android 外壳构建分支** | ❌ 不部署,只触发 APK 构建 | 构建前须与 `master` 同步 |
> **关于 `main`**:团队约定里的「main 主分支」在本仓库落地为 **`master`**(仓库默认分支即 `master`,不另建 `main`)。 > **关于 `main`**:团队约定里的「main 主分支」在本仓库落地为 **`master`**(仓库默认分支即 `master`,不另建 `main`)。
> 下文所有「main 主分支」均指 `master`。 > 下文所有「main 主分支」均指 `master`。
@@ -45,35 +68,48 @@ android-1.0 ── 从 master 同步代码后 push ──▶ 只触发 APK 构
> 此前 `dev-*` 与 `master` 共用同一套生产环境,推送开发分支等于直接发布,容易把半成品带上线。 > 此前 `dev-*` 与 `master` 共用同一套生产环境,推送开发分支等于直接发布,容易把半成品带上线。
> 现在开发分支的提交只有合并进 `master` 并推送后才会部署。 > 现在开发分支的提交只有合并进 `master` 并推送后才会部署。
---
## 版本管理 ## 版本管理
### 一句话规则 ### 版本号构成与口径(2026-10-25 决议)
**同一个版本号,APK(原生包)裸写,OTA 热更包多一个 `-ota` 后缀。**
``` ```
分支 dev-0.2 → 版本 0.2.0 V MAJOR.MINOR.PATCH 例: V0.2.0
├─ APK / 桌面安装包 versionName = 0.2.0 (无后缀) │ └── PATCH 小版本号: 仅在明确提出时自增, 否则沿用 (记录见 CHANGELOG.md)
├─ 前端运行时 APP_VERSION = 0.2.0 (无后缀) └── MINOR 大版本迭代, 对应一个开发分支 (V0.2 ↔ dev-0.2)
└─ OTA 热更包 version = 0.2.0-ota (多一个 -ota)
``` ```
版本号遵循 `主版本.次版本.修订号`,**唯一手动管理点是 `package.json` 的 `version` 字段**(当前 `0.2.0`), **前端与外壳解绑,独立维护、独立发版**(2026-10-25 决议):
该字段只写裸版本号,**不允许带 `-ota`**——`-ota` 由 `scripts/build-ota.mjs` 打包时自动追加。
版本号必须与所在分支对应:`dev-0.2` 分支上就该是 `0.2.x`。 | 组 | 版本 | 管理点 | 产物 | 发版节奏 |
| --- | --- | --- | --- | --- |
| **前端** (Web/OTA) | `0.2.0` | `package.json` 的 `version`(唯一手动管理点,只写裸版本号,不允许带 `-ota`) | 前端运行时 `APP_VERSION` = `0.2.0`;OTA 包 `version` = `0.2.0-ota` | 随 master push 高频发版 |
| **外壳** | `0.2.0` | `src-tauri/tauri.conf.json` 的 `version`(须与 `Cargo.toml`/`Cargo.lock` 组内一致) | APK versionName、桌面安装包版本;后续 Windows 外壳同理 | 独立分支(如 `android-*`)低频发版 |
- 两组版本号**不要求相等**,CI(`scripts/ci/check-version.mjs`)只校验组内一致 + 前端版本与 `dev-<major>.<minor>` 分支前缀对应。
- 分支口径:**dev-0.2 开发,发布一律走 master**。
### build 标识
- 格式统一为 **`YYYYMMDDHHmmss`(北京时间)**:
- 前端 `BUILD_ID`:vite 编译时生成(`vite.config.js#makeBuildId`),无需手动维护;
- OTA 包 `build_id`:打包时按同一格式生成(`scripts/build-ota.mjs`)。
- OTA 包的 `sequence`(Unix 毫秒,单调递增)保留,仅用于判新与热插拔协议,不再用于展示。
### PATCH 小版本号
- 仅在**明确提出**时自增(改 `package.json` version 第三位),否则一直沿用。
- 每次自增在 [CHANGELOG.md](CHANGELOG.md) 追加一行(patch 号 / 日期 / commit / 要点)。
### 改版本号时要同步的位置 ### 改版本号时要同步的位置
| 文件 | 字段 | 说明 | | 组 | 文件 | 字段 | 说明 |
| --- | --- | --- | | --- | --- | --- | --- |
| `package.json` | `version` | **唯一源**,前端 `src/version.js` 与 OTA 打包脚本均读取此值 | | 前端 | `package.json` | `version` | **前端唯一源**,`src/version.js` 与 OTA 打包脚本均读取此值 |
| `src-tauri/tauri.conf.json` | `version` | Tauri 应用版本 → **APK 的 versionName**、桌面安装包版本 | | 外壳 | `src-tauri/tauri.conf.json` | `version` | APK 的 versionName、桌面安装包版本 |
| `src-tauri/Cargo.toml` | `[package].version` | Rust 包版本,需与 tauri.conf.json 一致 | | 外壳 | `src-tauri/Cargo.toml` | `[package].version` | Rust 包版本,须与 tauri.conf.json 一致 |
| `src-tauri/Cargo.lock` | `name = "iboard"` 条目 | 同上,随 Cargo.toml 一起改 | | 外壳 | `src-tauri/Cargo.lock` | `name = "iboard"` 条目 | 随 Cargo.toml 一起改 |
`scripts/ci/check-version.mjs` 会在 CI 第一段就校验这四处**完全相等**且不含 `-ota`,
并校验 `dev-<major>.<minor>` 分支与版本前缀对应;任何一项不满足直接构建失败(`main`/`master` 只跳过分支校验,一致性照样查)。
### APK 版本号是怎么来的 ### APK 版本号是怎么来的
@@ -86,70 +122,36 @@ tauri.android.versionCode = 2000 # 整数,升级判定用 = major*1000000
``` ```
注意: 注意:
- `versionCode` 必须**单调递增**才能覆盖安装,所以版本号只能往上走(`0.2.0` → 2000 > `0.1.0` → 1000)。 - `versionCode` 必须**单调递增**才能覆盖安装,所以外壳版本号只能往上走(`0.2.0` → 2000 > `0.1.0` → 1000)。
- 本地如果 APK 装完显示旧版本号,是 `src-tauri/gen/android` 缓存没刷新, - 本地如果 APK 装完显示旧版本号,是 `src-tauri/gen/android` 缓存没刷新,
删掉该目录重新 `npx tauri android init` 即可。 删掉该目录重新 `npx tauri android init` 即可。
### 其它 ### 其它
- `src/version.js` 从 `package.json` 取值,前端"我"页与 OTA 检查接口展示的即此版本,不要另写死版本号。 - `src/version.js` 从 `package.json` 取值,前端运行时与 OTA 检查接口展示的即此版本,不要另写死版本号。
- OTA 升级包的 `version` = `<package.json version>-ota`(见 `scripts/build-ota.mjs`),
所以版本号错位会直接导致线上 OTA 判新失准。
- **`min_binary_version` 与这里的版本号是两件事**:它是 OTA 包要求的最低原生二进制版本 - **`min_binary_version` 与这里的版本号是两件事**:它是 OTA 包要求的最低原生二进制版本
(CI 里由 `OTA_MIN_BINARY` 显式指定,当前 `0.1.0`)。前端版本升到 0.2.0 时它不应跟着抬, (CI 里由 `OTA_MIN_BINARY` 显式指定,当前 `0.1.0`)。前端版本升到 0.2.0 时它不应跟着抬,
否则装了 0.1.0 APK 的设备会直接收不到热更。 否则装了 0.1.0 APK 的设备会直接收不到热更。
- `BUILD_ID` 每次编译自动生成(见 `vite.config.js` 的 `__BUILD_ID__`),无需手动维护。
- `ota-package/latest.json` 为运行时产物,不纳入版本管理。 - `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` 验证 → 提交迁移目录。
- 首页主要是放经济文章
- 数据主要是放各种经济数据和图表。
- 策略是放按照经济理论组织的相关数据和图表的对比。
## 首页
首页显示新闻模块和报警模块的数据,按照时间最新优先,无限滚动,直到数据库中没有数据为止。
### 新闻模块
增加一个新闻模块,数据来源是每日夜间 3点,通过妙想 MCP 获取国内外最新经济政治新闻,按影响大小排序10条。
存入数据库。
### 报警模块
World Bank 中的数据只用来对比妙想 MCP 拉取的数据正确性。当同一时间的同一项数据不同时,记录一个报警。
## 监控
监控分为服务器监控和服务监控两部分。
服务器监控对接 komari,地址:https://komari.honor3.com/
| 行 | 展示 |
| --- | --- |
| 当前版本 | `v0.2.0 · build 20261025204300`(前端 APP_VERSION + BUILD_ID 原始格式) |
| 运行资源 | OTA 覆盖包版本 / 内置 |
| 新版本 | `v0.2.0-ota · build 20261025210000`(OTA 包 build_id) |
| 发布说明 | 触发构建的 commit message |
| 提交 | commit sha 前 10 位 |
| 页面底部 | `外壳 v0.2.0`(Tauri API 读取;Web 端显示 `—`) |
---
## 前端 OTA 升级 ## 前端 OTA 升级
只升级前端资源 (`dist/`), **不触碰** Tauri 框架/原生层, 不重打 APK. 只升级前端资源 (`dist/`),**不触碰** Tauri 框架/原生层,不重打 APK。
底层使用 [`tauri-plugin-hotswap`](https://github.com/denniskribl/tauri-plugin-hotswap) 底层使用 [`tauri-plugin-hotswap`](https://github.com/denniskribl/tauri-plugin-hotswap)
(签名/回滚/资源替换全部由插件处理). (签名/回滚/资源替换全部由插件处理)。
### 工作流 ### 工作流
@@ -170,6 +172,12 @@ scripts/build-ota.mjs │ 生成 iboard-ota.tar.gz + minisign 签名
location.reload() ─▶ 后续资源从 tauri://localhost 优先命中覆盖包 location.reload() ─▶ 后续资源从 tauri://localhost 优先命中覆盖包
``` ```
### 发布说明与提交号(2026-10-25 起)
- CI(docker-build.yml)把**触发构建的 commit message**(标题+正文,去空行,截断 400 字)作为 `notes` 写入 latest.json;
- commit sha 经 `--commit` 参数单独写入 `commit` 字段;
- "我的"页:发布说明展示 commit message,「提交」行展示短 sha。
### 首次部署 ### 首次部署
1. **生成签名密钥** (一次性): 1. **生成签名密钥** (一次性):
@@ -178,8 +186,7 @@ scripts/build-ota.mjs │ 生成 iboard-ota.tar.gz + minisign 签名
node scripts/ota-genkey.mjs node scripts/ota-genkey.mjs
# 产物: ota-package/hotswap.{key, pub} # 产物: ota-package/hotswap.{key, pub}
``` ```
2. **把公钥写入 `src-tauri/tauri.conf.json`** 的 `plugins.hotswap.pubkey` 字段 2. **把公钥写入 `src-tauri/tauri.conf.json`** 的 `plugins.hotswap.pubkey` 字段(脚本会打印)。
(脚本会打印).
3. **配置 endpoint** (`src-tauri/tauri.conf.json`): 3. **配置 endpoint** (`src-tauri/tauri.conf.json`):
```json ```json
"hotswap": { "hotswap": {
@@ -188,7 +195,7 @@ scripts/build-ota.mjs │ 生成 iboard-ota.tar.gz + minisign 签名
"require_https": true "require_https": true
} }
``` ```
4. **服务端环境变量** `OTA_PACKAGE_DIR` 指向 `ota-package/` 所在目录. 4. **服务端环境变量** `OTA_PACKAGE_DIR` 指向 `ota-package/` 所在目录。
### 发布新版前端 ### 发布新版前端
@@ -196,20 +203,20 @@ scripts/build-ota.mjs │ 生成 iboard-ota.tar.gz + minisign 签名
npm run build # 生成 dist/ npm run build # 生成 dist/
node scripts/build-ota.mjs # 打成 tar.gz + 签名 + latest.json node scripts/build-ota.mjs # 打成 tar.gz + 签名 + latest.json
# sequence 默认 = Date.now(), 也可 --sequence 43 手动指定 # sequence 默认 = Date.now(), 也可 --sequence 43 手动指定
# 也可加 --notes "修复登录按钮" --min-binary 0.1.0 # 也可加 --notes "修复登录按钮" --commit <sha> --min-binary 0.1.0
``` ```
然后把 `ota-package/` 下两个文件 (`iboard-ota.tar.gz` + `latest.json`) 同步到 然后把 `ota-package/` 下两个文件 (`iboard-ota.tar.gz` + `latest.json`) 同步到
服务端 `OTA_PACKAGE_DIR` 指向的目录. sequence 单调递增即可. 服务端 `OTA_PACKAGE_DIR` 指向的目录。sequence 单调递增即可。
### 客户端使用 ### 客户端使用
打开 App 的"我"页, 点 [检查更新] → [立即升级] 即可. 失败会自动回滚到内置 dist. 打开 App 的"我"页,点 [检查更新] → [立即升级] 即可。失败会自动回滚到内置 dist。
> **注意:** Tauri 2 的 dev 模式 (`tauri dev`) 会代理到 Vite dev server, **OTA 不生效**. > **注意:** Tauri 2 的 dev 模式 (`tauri dev`) 会代理到 Vite dev server,**OTA 不生效**。
> OTA 仅在 release/打包构建中可用, 测试请用 `tauri build` 产物. > OTA 仅在 release/打包构建中可用,测试请用 `tauri build` 产物。
### 客户端判新口径(V0.2 新增) ### 客户端判新口径(V0.2)
"我"页 OTA 组内有一个 **开发版本** 开关(默认关闭),切换"怎么算新版本": "我"页 OTA 组内有一个 **开发版本** 开关(默认关闭),切换"怎么算新版本":
@@ -226,25 +233,31 @@ node scripts/build-ota.mjs # 打成 tar.gz + 签名 + latest.json
### Web 端 ### Web 端
Web 浏览器访问 (同源) 时, 服务端返回简化版 manifest, 仅显示"有新版本请刷新". Web 浏览器访问 (同源) 时,服务端返回简化版 manifest,仅显示"有新版本请刷新"。
Web 端不会自动下载包, 也不会替换前端代码. Web 端不会自动下载包,也不会替换前端代码。
### CI 自动签名 (Gitea Actions) ### CI 自动签名 (Gitea Actions)
.gitea/workflows/ota-build.yml 是手动触发的 OTA 打包工作流 (每次发版前点一次). .gitea/workflows/ota-build.yml 是手动触发的 OTA 打包工作流(每次发版前点一次)。
**配置 secret** (Gitea 仓库 -> Settings -> Actions -> Secrets): **配置 secret** (Gitea 仓库 -> Settings -> Actions -> Secrets):
- MINISIGN_PRIVATE_KEY: 完整 .key 文件内容, **整段** (含首行 `untrusted comment: minisign encrypted secret key` 和 base64 行), 整段作为多行 secret 粘贴即可. - MINISIGN_PRIVATE_KEY: 完整 .key 文件内容,**整段**(含首行 `untrusted comment: minisign encrypted secret key` 和 base64 行),整段作为多行 secret 粘贴即可。
**使用流程**: **使用流程**:
1. 在 Gitea Actions 页面选 OTA Frontend Build & Sign -> Run workflow 1. 在 Gitea Actions 页面选 OTA Frontend Build & Sign -> Run workflow
2. 填 sequence (可选, 不填用时间戳) 和 2. 填 sequence(可选,不填用时间戳)和 notes(可选)
otes (可选)
3. CI 跑完会在 artifact 里看到 ota-package/iboard-ota.tar.gz + latest.json + iboard-ota.tar.gz.minisig 3. CI 跑完会在 artifact 里看到 ota-package/iboard-ota.tar.gz + latest.json + iboard-ota.tar.gz.minisig
4. 下载 artifact, scp 到服务器 OTA_PACKAGE_DIR 目录 4. 下载 artifact,scp 到服务器 OTA_PACKAGE_DIR 目录
5. 客户端"我"页 [检查更新] 即可触发升级 5. 客户端"我"页 [检查更新] 即可触发升级
**注意**: **注意**:
- CI 用的私钥必须**与** auri.conf.json 里 plugins.hotswap.pubkey **配对**; 换密钥时需要同时更新 CI secret 和 conf. - CI 用的私钥必须**与** `tauri.conf.json` 里 plugins.hotswap.pubkey **配对**;换密钥时需要同时更新 CI secret 和 conf。
- 不要把私钥 commit 到 git, 整个 ota-package/hotswap.key 已被 .gitignore 排除. - 不要把私钥 commit 到 git,整个 ota-package/hotswap.key 已被 .gitignore 排除。
### 数据库升级约定
- 唯一入口是 `prisma/migrations/` 下的迁移文件,由 `npx prisma migrate deploy` 在**启新容器之前**执行
(幂等,无新迁移时为 no-op);迁移失败会中断 workflow,不会用新镜像顶掉仍在运行的旧容器。
- 由于只有 `master` 会部署,**迁移真正作用到生产库的唯一时机就是 master 发布**;
开发分支上的迁移不会被自动应用,请在合并到 `master` 前确认可回滚。
- 本地改了 `prisma/schema.prisma` 后:写迁移 SQL → `npx prisma migrate deploy` 验证 → 提交迁移目录。
@@ -155,6 +155,9 @@
| T-TK-11 | `TaskMember` 关联表 | 多对多,`@@unique([taskId,userId])` 去重 | | T-TK-11 | `TaskMember` 关联表 | 多对多,`@@unique([taskId,userId])` 去重 |
| T-TK-12 | `TaskTag` 表 + 卡片标签渲染 | 多标签,展示于卡片/条目 | | T-TK-12 | `TaskTag` 表 + 卡片标签渲染 | 多标签,展示于卡片/条目 |
| T-TK-13 | 服务端维护 | `creatorId`/`createdAt` 不可改;`updatedAt` 自动刷新 | | T-TK-13 | 服务端维护 | `creatorId`/`createdAt` 不可改;`updatedAt` 自动刷新 |
| T-TK-14 | 标题 30 字上限 + 快速添加截断 | 服务端 create/update 校验 `title ≤ 30`;前端 `quickAdd.js#splitTitle`:≤30 原样,超长在 30 字内(≥8 字)找最后一个分隔符截断,前段为标题、**完整原文**写入 `description`;快速添加输入框 `maxLength=200` |
| T-TK-15 | `Task.markDate DATETIME(3) NULL`(迁移 20261025120000) | create/update 接受 `YYYY-MM-DD`(空/null 清除),完整日期存储;详情页 DatePicker(precision=day)选择/清除;卡片展示 `MM-dd` 小标签(`date-tag`) |
| T-TK-16 | `quickAdd.js#parseTimePrefix` | 前缀词表:今天/明天/后天/本周/下周/本月/下月(须后跟分隔符或结束);今天=当日,明天/后天=+1/+2,本周=本周日、下周=下周日(周一为一周开始),本月/下月=当月/下月最后一天;命中后前缀移出标题并写入 `markDate` |
--- ---
@@ -176,8 +176,10 @@
| `projectId` | `INT` | 否 | — | FK → `Project.id` `ON DELETE CASCADE` | | `projectId` | `INT` | 否 | — | FK → `Project.id` `ON DELETE CASCADE` |
| `columnId` | `INT` | 否 | — | FK → `BoardColumn.id` `ON DELETE RESTRICT`(T-BD-01) | | `columnId` | `INT` | 否 | — | FK → `BoardColumn.id` `ON DELETE RESTRICT`(T-BD-01) |
| `parentId` | `INT` | **是** | `NULL` | 非空 = 一级子任务;FK → `Task.id` `ON DELETE CASCADE`(T-ST-01/11) | | `parentId` | `INT` | **是** | `NULL` | 非空 = 一级子任务;FK → `Task.id` `ON DELETE CASCADE`(T-ST-01/11) |
| `title` | `VARCHAR(200)` | 否 | — | 唯一必填(T-TK-02) | | `title` | `VARCHAR(200)` | 否 | — | 唯一必填(T-TK-02);应用层限 30 字(T-TK-14) |
| `description` | `TEXT` | 否 | `''` | — | | `description` | `TEXT` | 否 | `''` | 快速添加超长文本的余文并入此处(T-TK-14) |
| `kind` | `VARCHAR(20)` | **是** | `NULL` | 任务类型标签,取值见 `Project.taskKinds` |
| `markDate` | `DATETIME(3)` | **是** | `NULL` | 时间标记,完整日期存储、展示 `MM-dd`(T-TK-15;迁移 20261025120000) |
| `status` | `VARCHAR(8)` | 否 | `'todo'` | `todo`/`doing`/`done`(T-TK-03) | | `status` | `VARCHAR(8)` | 否 | `'todo'` | `todo`/`doing`/`done`(T-TK-03) |
| `doneAt` | `DATETIME(3)` | **是** | `NULL` | 完成时刻;**保留选项判定依据**(T-FL-10) | | `doneAt` | `DATETIME(3)` | **是** | `NULL` | 完成时刻;**保留选项判定依据**(T-FL-10) |
| `assigneeId` | `INT` | **是** | `NULL` | 负责人(单人);新建默认创建者(T-TK-09) | | `assigneeId` | `INT` | **是** | `NULL` | 负责人(单人);新建默认创建者(T-TK-09) |
@@ -240,6 +240,9 @@
| T-TK-11 | 每条任务有**任务成员(多人)** | 可设置 0 ~ 多个成员,成员不重复 | P0 | | T-TK-11 | 每条任务有**任务成员(多人)** | 可设置 0 ~ 多个成员,成员不重复 | P0 |
| T-TK-12 | 任务支持**标签** | 可添加多个标签,展示于条目 / 卡片 | P1 | | T-TK-12 | 任务支持**标签** | 可添加多个标签,展示于条目 / 卡片 | P1 |
| T-TK-13 | 任务的创建人、创建时间、更新时间由系统维护,用户不可直接编辑 | 编辑任务不改变创建人与创建时间;任何修改刷新更新时间 | P1 | | T-TK-13 | 任务的创建人、创建时间、更新时间由系统维护,用户不可直接编辑 | 编辑任务不改变创建人与创建时间;任何修改刷新更新时间 | P1 |
| T-TK-14 | 任务标题收紧至**30 个汉字**;快速添加输入框允许输入 200 字,超过 30 字时自动在 30 字以内的**分隔符(空格/标点等)**处截断,前段作为标题,整体文本并入描述 | 输入长文本创建后:标题 ≤30 字且不在词中间切断;描述包含完整原文余文;直接编辑标题超 30 字时拒绝保存并提示 | P0 |
| T-TK-15 | 任务可选**时间标记**(完整日期存储),设置了时间标记的任务在列表行与看板卡片上以 `MM-dd` 小标签展示 | 详情页可选择/清除时间标记;设置后卡片出现 MM-dd 标签,清除后消失 | P1 |
| T-TK-16 | 快速添加输入框支持**时间词前缀**:以 今天/明天/后天/本周/下周/本月/下月 开头(后跟分隔符)时,自动为任务设置对应时间标记并把该前缀从标题中移除 | 输入「明天 交周报」→ 创建任务标题为「交周报」、时间标记为明天的日期;无匹配前缀时行为不变 | P1 |
### 3.6 负责人 & 成员 UI 展示规则(T-AS) ### 3.6 负责人 & 成员 UI 展示规则(T-AS)
@@ -0,0 +1,127 @@
# 基于 PowerSync 的离线编辑可行性研究报告
| 项 | 内容 |
| --- | --- |
| 版本 | V0.2 |
| 日期 | 2026-10-25 |
| 评估对象 | PowerSync(powersync.com,开源 self-hosted + 云服务) |
| 评估目标 | 项目离线操作(本地编辑)+ 联网后自动同步 |
| 结论(TL;DR) | **技术上可行,但落地成本高、风险中高。不建议全量接入;若确需离线,建议仅对「打卡 / 清单 / 任务」等用户编辑域做局部接入,且需要先解决 MariaDB 兼容与主键策略两个前置问题。** |
---
## 1. 项目现状约束(评估输入)
结合代码库(`package.json`、`prisma/schema.prisma`、`server/index.js`)梳理:
1. **前端**:React 18 SPA(antd-mobile),通过 axios 调 `/api/*` REST 接口;`src/api`、`src/hooks`、`src/pages` 均以"请求 → state → 渲染"模式组织,无任何本地持久化数据层。
2. **桌面/移动端**:Tauri 2(Windows WebView2 桌面 + Android System WebView),意味着前端运行在 WebView 内,浏览器类 SDK 是候选。
3. **后端**:Express + Prisma,数据库为 **MariaDB / MySQL**(`provider = "mysql"`)。
4. **认证**:自签 JWT(jsonwebtoken),登录接口在 `/api/auth`。
5. **写路径**:全部业务写操作走 REST(打卡、清单、任务模块);同时存在大量**服务端定时写入**(cron:新闻、指标、监控、米家采样等,见 `server/cron.js`)。
6. **数据模型**:约 25 张表,主键全部为 `Int @id @default(autoincrement())` 自增整数,多表带复合唯一约束(如 `CheckIn @@unique([habitId, date, seq])`)。
7. **数据体量特征**:两类数据并存——
- **用户编辑域**(Habit / CheckIn / CheckList* / 任务模块 10 张表):冲突敏感、离线价值高;
- **服务端只读时序域**(News / Indicator / ServerNodeHistory / MihomeEnv* 等):append-only、量大、离线价值中等(缓存即可满足)。
---
## 2. PowerSync 是什么、怎么工作
- 架构:客户端本地 **SQLite**(Web 端为 WASM SQLite,存 OPFS/IndexedDB)↔ **PowerSync Service**(self-hosted 开源组件或云服务)↔ 源数据库。服务端通过 **逻辑复制/变更流** 捕获源库写入,按 **Sync Rules** 定义的下推子集同步到每个客户端的本地 SQLite。
- 写路径(官方推荐模式):客户端离线写本地 SQLite → 进入 **upload queue** → 联网后由前端把变更批量 POST 到**现有后端 API** → 后端用原有方式(本项目即 Prisma)写库 → 变更流捕获 → 广播回各端。
- 认证:客户端持后端签发的 JWT 连接同步服务;PowerSync 只做校验,不签发(可复用现有 jsonwebtoken 体系)。
- 服务端直写的表(cron 产生)也能被变更流捕获并下推,不需要改造写入方。
参考:[PowerSync Service(GitHub)](https://github.com/powersync-ja/powersync-service)、[源数据库支持](https://docs.powersync.com/configuration/source-db/setup)。
---
## 3. 逐项可行性分析
### 3.1 数据库兼容性 —— ⚠️ 主要风险点
- PowerSync 对源库支持:**PostgreSQL(GA)**、MongoDB、SQL Server;**MySQL 仍处于 Beta**(基于 binlog 复制,见 [MySQL Beta 公告](https://releases.powersync.com/announcements/mysql-backend-database-support-has-reached-beta)、[Alpha 公告](https://releases.powersync.com/announcements/announcing-mysql-as-a-source-database-alpha))。
- 本项目用的是 **MariaDB**。MariaDB 的 binlog 格式与 MySQL 8 有差异(GTID、行事件细节),PowerSync MySQL 插件(@powersync/mysql-zongji 系)对 MariaDB **没有官方兼容承诺,需要实测验证**,很可能踩坑。
- **结论**:要么实测 MariaDB + MySQL-beta 通路(风险高),要么把数据库迁到 PostgreSQL(PowerSync 最成熟路径,Prisma 迁移成本可控但涉及全部表和线上数据)。这是"能不能用"的第一道门槛。
### 3.2 数据模型适配 —— 改造成本大
- PowerSync Sync Rules 要求同步表有稳定的行标识,**强烈建议 UUID 文本主键**;而本项目全部是自增整数主键,且客户端离线创建记录时无法预知自增 id。
- 离线创建的通用解法:主键改为客户端生成的 UUID(`char(36)`),或新增 `uuid` 列 + 应用层幂等 upsert。对用户编辑域的 10 余张表意味着**一次不小的 schema 迁移 + 全部相关路由改造**(迁移原则"只增不改"仍可遵守,但外键列类型变更工作量大)。
- `DateTime`、`Json` 类型可映射(客户端以 ISO 文本/JSON 文本存储),但 `BigInt`(监控表)在 JS 端需按文本处理,注意精度。
- 复合唯一约束(`CheckIn(habitId,date,seq)`、`BoardColumn(projectId,name)` 等)在离线多端并发下会冲突,需在 upload 处理端路由里做服务端仲裁(如 seq 重排、重名加后缀),现有路由大多不具备幂等/仲裁语义。
### 3.3 写路径(离线编辑 + 上行同步)—— 模式契合,但需逐路由改造
- PowerSync 的 upload-queue → 后端 API 模式与现有 Express 架构**天然契合**:不需要后端暴露直接写库的通口,各业务路由补成"接收客户端 UUID + 幂等 upsert"即可。
- 打卡/清单/任务的路由数量约 3 个 router 文件(checkin / checklists / projects / tasks),逐个改造为幂等是可控的。
- 服务端 cron 写入的表(新闻/指标/监控/米家)无需改造写入方,只需在 Sync Rules 里定义是否下推。
### 3.4 读路径与前端数据层 —— 全面前重写
这是**工作量最大**的部分:
- 现有 `src/api` + axios + useEffect 模式必须替换为 PowerSync 本地 SQLite 查询 + `@powersync/react` 的响应式 hooks(useQuery 等),否则离线读不到数据。
- 涉及 `src/pages` 下全部用户编辑域页面(checkin、task),以及若要离线浏览还需覆盖 news/macro/monitor 的只读缓存页。
- 工作量量级:相当于把前端数据层重构一遍;可与"离线只读缓存(HTTP 缓存)"分层实施以降低风险。
### 3.5 客户端运行环境 —— 大体可行,Android 需验证
- **浏览器 / Tauri 桌面(WebView2)**:PowerSync Web SDK(WASM SQLite + OPFS)在 Chromium 系 WebView 已被官方支持,可行。
- **Tauri Android(System WebView)**:WASM SQLite 性能与 OPFS 在部分国产/旧版 WebView 上存在兼容性风险,需真机验证;备选方案是封装原生 SQLite(官方 Capacitor 插件思路可借鉴,但 Tauri 无官方插件,需自研 Rust 命令桥接,成本额外增加)。
- 时序大表(ServerNodeHistory 每分钟一条、MihomeEnv*)不建议纳入同步——Sync Rules 需做时间窗过滤(如仅近 7 天),否则客户端存储和同步流量都会失控。
### 3.6 认证与部署 —— 可行
- JWT 可复用:新增一个 `/api/powersync/token` 端点为已登录用户签发 PowerSync 接受的 token,改造小。
- 部署:self-hosted 需在现有 Express 之外多跑一个 powersync-service 进程(含 Mongo 存储元数据或云实例),并暴露 WebSocket 端口;或使用 PowerSync Cloud。运维面增加一个组件。
### 3.7 冲突解决
- PowerSync 本身不做自动合并,只提供上传队列与冲突回调;对"最后写入胜出"友好的字段(清单勾选、任务标题/状态)默认策略够用;对 `CheckIn.seq`、`BoardColumn.sortOrder` 这类"位置/序号型"数据需要自定义仲裁逻辑(上传端或同步流中处理),设计上可解,但要专门做。
---
## 4. 风险与成本汇总
| # | 风险/成本项 | 等级 | 说明 |
| --- | --- | --- | --- |
| 1 | MariaDB 兼容性未验证,MySQL 支持仅 Beta | 高 | 很可能需要迁移 PostgreSQL |
| 2 | 前端数据层全面重构(axios → 本地 SQLite 查询) | 高 | 涉及所有相关页面 |
| 3 | 用户编辑域主键整数自增 → UUID 迁移 | 中高 | schema + 路由幂等改造 |
| 4 | 复合唯一约束的离线冲突仲裁 | 中 | CheckIn.seq、列名、排序等 |
| 5 | Android WebView WASM SQLite/OPFS 兼容 | 中 | 需真机验证 |
| 6 | 新增 self-hosted 组件的运维 | 中 | 多一个常驻服务 |
| 7 | 时序大表同步范围控制 | 低 | Sync Rules 时间窗过滤即可 |
对照收益:真正需要离线**编辑**的只有打卡 / 清单 / 任务三个域(数据量小、单用户为主、冲突场景有限);其余域离线**只读缓存**即可满足。
---
## 5. 结论与建议
### 5.1 结论
**可行,但不建议以"全项目接入 PowerSync"的方式落地。** PowerSync 的同步模型与项目的 Express API 写路径契合、认证可复用、桌面端可行;但 MariaDB/MySQL-beta 兼容、整数自增主键、前端数据层全面重构三座大山使总成本明显高于当前阶段的收益。
### 5.2 建议(按优先序)
1. **首选(低成本方案)**:不引入 PowerSync,为用户编辑域实现轻量离线队列——前端把写请求落到 localStorage/IndexedDB 待发队列(含客户端生成的 UUID),联网后重放到现有 REST API(路由补幂等 upsert);只读页用 HTTP 缓存 + SW。可覆盖约 80% 的离线诉求,改动局限在 `src/api` 层与少量路由。
2. **若决定采用 PowerSync**:
- 前置 1:源库迁移 PostgreSQL(避开 MySQL Beta + MariaDB 兼容雷区);
- 前置 2:仅将用户编辑域(Habit/CheckIn/CheckList*/任务模块)纳入 Sync Rules,时序与监控表排除或做时间窗过滤;
- 前置 3:这些表的主键/唯一标识改造为客户端可生成的 UUID;
- 分阶段:先桌面端(WebView2)验证全链路,再攻 Android WebView 兼容性;
- 冲突策略:字段级 LWW 为主,seq/sortOrder 类服务端仲裁。
3. **不建议**:为离线只读域(新闻/指标/监控)上 PowerSync——用简单缓存即可,同步引擎属于过度设计。
---
## 6. 参考资料
- [PowerSync Service(GitHub,self-hosted 开源)](https://github.com/powersync-ja/powersync-service)
- [PowerSync 源数据库配置文档](https://docs.powersync.com/configuration/source-db/setup)
- [MySQL 后端支持 Beta 公告](https://releases.powersync.com/announcements/mysql-backend-database-support-has-reached-beta)
- [MySQL 后端支持 Alpha 公告](https://releases.powersync.com/announcements/announcing-mysql-as-a-source-database-alpha)
- 本项目:`prisma/schema.prisma`、`server/index.js`、`docs/V0.2/数据库设计文档.md`、`docs/V0.2/任务模块/任务模块数据库设计文档.md`
+1 -1
View File
@@ -3,7 +3,7 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <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, interactive-widget=resizes-content" /> <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" /> <meta name="theme-color" content="#1f2937" />
<title>iBoard - 经济数据与策略</title> <title>iBoard - 经济数据与策略</title>
</head> </head>
@@ -0,0 +1,3 @@
-- 任务类型标签: 项目级自定义列表 + 任务上的类型
ALTER TABLE `Project` ADD COLUMN `taskKinds` JSON NULL;
ALTER TABLE `Task` ADD COLUMN `kind` VARCHAR(20) NULL;
@@ -0,0 +1,2 @@
-- 任务时间标记: 完整日期存储, 前端展示 MM-dd
ALTER TABLE `Task` ADD COLUMN `markDate` DATETIME(3) NULL;
@@ -0,0 +1,2 @@
-- 项目图标: data URL (客户端裁剪 96x96 JPEG, 约 10-20KB)
ALTER TABLE `Project` ADD COLUMN `icon` TEXT NULL;
+4
View File
@@ -394,6 +394,8 @@ model Project {
isPersonal Boolean @default(false) isPersonal Boolean @default(false)
archived Boolean @default(false) archived Boolean @default(false)
notes Json @default("[]") // 项目信息块 [{ title, content }] (T-PJ-13) notes Json @default("[]") // 项目信息块 [{ title, content }] (T-PJ-13)
taskKinds Json? // 任务类型标签列表 (未配置时服务端回落默认四类)
icon String? @db.Text // 项目图标 (data URL, 客户端裁剪为 96x96 JPEG)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@ -447,6 +449,8 @@ model Task {
title String @db.VarChar(200) title String @db.VarChar(200)
description String @default("") @db.Text description String @default("") @db.Text
kind String? @db.VarChar(20) // 任务类型标签 (项目 taskKinds 之一)
markDate DateTime? // 时间标记 (完整日期存储, 展示为 MM-dd)
status String @default("todo") @db.VarChar(8) status String @default("todo") @db.VarChar(8)
doneAt DateTime? doneAt DateTime?
+157 -147
View File
@@ -1,147 +1,157 @@
#!/usr/bin/env node #!/usr/bin/env node
// OTA 升级包生成脚本 (兼容 tauri-plugin-hotswap) // OTA 升级包生成脚本 (兼容 tauri-plugin-hotswap)
// 用法: // 用法:
// node scripts/build-ota.mjs [--out <dir>] [--sequence <n>] [--min-binary <x>] [--notes <s>] [--no-sign] [--key <path>] // node scripts/build-ota.mjs [--out <dir>] [--sequence <n>] [--min-binary <x>] [--notes <s>] [--no-sign] [--key <path>]
// 流程: // 流程:
// 1) 校验 dist/ 已构建 // 1) 校验 dist/ 已构建
// 2) 生成 bundle.tar.gz (dist/ 内容) // 2) 生成 bundle.tar.gz (dist/ 内容)
// 3) minisign 签名 (除非 --no-sign; 私钥优先读 MINISIGN_PRIVATE_KEY 环境变量) // 3) minisign 签名 (除非 --no-sign; 私钥优先读 MINISIGN_PRIVATE_KEY 环境变量)
// 4) 写 latest.json (manifest, 含 sequence/version/signature/bundle_size/notes/...) // 4) 写 latest.json (manifest, 含 sequence/version/signature/bundle_size/notes/...)
// 5) 全部产物输出到 out 目录 (默认 ota-package/) // 5) 全部产物输出到 out 目录 (默认 ota-package/)
// //
// minisign 安装: 见 readme "OTA 升级" 章节 // minisign 安装: 见 readme "OTA 升级" 章节
import fs from 'node:fs' import fs from 'node:fs'
import path from 'node:path' import path from 'node:path'
import crypto from 'node:crypto' import crypto from 'node:crypto'
import { fileURLToPath } from 'node:url' import { fileURLToPath } from 'node:url'
import { spawnSync } from 'node:child_process' import { spawnSync } from 'node:child_process'
const __filename = fileURLToPath(import.meta.url) const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename) const __dirname = path.dirname(__filename)
const ROOT = path.resolve(__dirname, '..') const ROOT = path.resolve(__dirname, '..')
const args = process.argv.slice(2) const args = process.argv.slice(2)
function getArg(name, def) { function getArg(name, def) {
const i = args.indexOf(name) const i = args.indexOf(name)
return i >= 0 && args[i + 1] ? args[i + 1] : def return i >= 0 && args[i + 1] ? args[i + 1] : def
} }
const OUT_DIR = path.resolve(getArg('--out', 'ota-package')) const OUT_DIR = path.resolve(getArg('--out', 'ota-package'))
const SEQUENCE = Number(getArg('--sequence', String(Date.now()))) // 默认用时间戳做单调计数 const SEQUENCE = Number(getArg('--sequence', String(Date.now()))) // 默认用时间戳做单调计数
const MIN_BINARY = getArg('--min-binary', '') const MIN_BINARY = getArg('--min-binary', '')
const NOTES = getArg('--notes', '') const NOTES = getArg('--notes', '')
const NO_SIGN = args.includes('--no-sign') // 构建对应的 git commit sha (发布说明=commit message, sha 单独一行展示)
// 私钥优先来源: MINISIGN_PRIVATE_KEY 环境变量 (CI 注入) → 文件 (--key 路径) const COMMIT = getArg('--commit', '')
const KEY_PATH = path.resolve(getArg('--key', path.join(OUT_DIR, 'hotswap.key'))) const NO_SIGN = args.includes('--no-sign')
const KEY_FROM_ENV = !!process.env.MINISIGN_PRIVATE_KEY // 私钥优先来源: MINISIGN_PRIVATE_KEY 环境变量 (CI 注入) → 文件 (--key 路径)
// 旧版 tauri.conf.json 里的 pubkey (自动同步过去, 方便用户复制) const KEY_PATH = path.resolve(getArg('--key', path.join(OUT_DIR, 'hotswap.key')))
const TAURI_CONF = path.join(ROOT, 'src-tauri', 'tauri.conf.json') const KEY_FROM_ENV = !!process.env.MINISIGN_PRIVATE_KEY
// 旧版 tauri.conf.json 里的 pubkey (自动同步过去, 方便用户复制)
const pkg = JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8')) const TAURI_CONF = path.join(ROOT, 'src-tauri', 'tauri.conf.json')
const APP_VERSION = pkg.version || '0.0.0'
const pkg = JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8'))
const DIST = path.join(ROOT, 'dist') const APP_VERSION = pkg.version || '0.0.0'
if (!fs.existsSync(path.join(DIST, 'index.html'))) {
console.error('[build-ota] 错误: dist/index.html 不存在, 请先执行 npm run build') const DIST = path.join(ROOT, 'dist')
process.exit(1) if (!fs.existsSync(path.join(DIST, 'index.html'))) {
} console.error('[build-ota] 错误: dist/index.html 不存在, 请先执行 npm run build')
process.exit(1)
// 准备输出目录 }
fs.mkdirSync(OUT_DIR, { recursive: true })
// 准备输出目录
// 1) 生成 bundle.tar.gz fs.mkdirSync(OUT_DIR, { recursive: true })
const BUNDLE_NAME = 'iboard-ota.tar.gz'
const BUNDLE_PATH = path.join(OUT_DIR, BUNDLE_NAME) // 1) 生成 bundle.tar.gz
const r = spawnSync('tar', ['-czf', BUNDLE_PATH, '-C', DIST, '.'], { stdio: 'inherit' }) const BUNDLE_NAME = 'iboard-ota.tar.gz'
if (r.status !== 0) { const BUNDLE_PATH = path.join(OUT_DIR, BUNDLE_NAME)
console.error('[build-ota] tar 打包失败') const r = spawnSync('tar', ['-czf', BUNDLE_PATH, '-C', DIST, '.'], { stdio: 'inherit' })
process.exit(1) if (r.status !== 0) {
} console.error('[build-ota] tar 打包失败')
const bundleStat = fs.statSync(BUNDLE_PATH) process.exit(1)
const bundleSha256 = crypto.createHash('sha256').update(fs.readFileSync(BUNDLE_PATH)).digest('hex') }
const bundleStat = fs.statSync(BUNDLE_PATH)
// 2) 签名 (minisign) const bundleSha256 = crypto.createHash('sha256').update(fs.readFileSync(BUNDLE_PATH)).digest('hex')
let signature = ''
let pubkey = '' // 2) 签名 (minisign)
if (!NO_SIGN) { let signature = ''
// CI 注入: 私钥走环境变量, 写到临时文件, 签名后立即删除 (避免落盘) let pubkey = ''
let KEY_PATH_TMP = null if (!NO_SIGN) {
let KEY_PATH_USE = KEY_PATH // CI 注入: 私钥走环境变量, 写到临时文件, 签名后立即删除 (避免落盘)
if (KEY_FROM_ENV) { let KEY_PATH_TMP = null
KEY_PATH_TMP = path.join(OUT_DIR, `.hotswap.key.${process.pid}`) let KEY_PATH_USE = KEY_PATH
fs.writeFileSync(KEY_PATH_TMP, process.env.MINISIGN_PRIVATE_KEY, { mode: 0o600 }) if (KEY_FROM_ENV) {
KEY_PATH_USE = KEY_PATH_TMP KEY_PATH_TMP = path.join(OUT_DIR, `.hotswap.key.${process.pid}`)
} fs.writeFileSync(KEY_PATH_TMP, process.env.MINISIGN_PRIVATE_KEY, { mode: 0o600 })
if (!fs.existsSync(KEY_PATH_USE)) { KEY_PATH_USE = KEY_PATH_TMP
console.error('[build-ota] 私钥不存在:', KEY_PATH_USE) }
console.error(' 注入方式二选一:') if (!fs.existsSync(KEY_PATH_USE)) {
console.error(' 1) CI: 设环境变量 MINISIGN_PRIVATE_KEY (整段 .key 文件内容, 含首行 untrusted comment)') console.error('[build-ota] 私钥不存在:', KEY_PATH_USE)
console.error(' 2) 本机: 放 ' + KEY_PATH + ', 或用 --key <path>') console.error(' 注入方式二选一:')
console.error(' 生成方法: minisign -G -W -p ota-package/hotswap.pub -s ota-package/hotswap.key') console.error(' 1) CI: 设环境变量 MINISIGN_PRIVATE_KEY (整段 .key 文件内容, 含首行 untrusted comment)')
process.exit(1) console.error(' 2) 本机: 放 ' + KEY_PATH + ', 或用 --key <path>')
} console.error(' 生成方法: minisign -G -W -p ota-package/hotswap.pub -s ota-package/hotswap.key')
// minisign -Sm <file> -s <key> 生成 detached signature process.exit(1)
const sigArgs = ['-Sm', BUNDLE_PATH, '-s', KEY_PATH_USE] }
const sigR = spawnSync('minisign', sigArgs, { stdio: 'inherit' }) // minisign -Sm <file> -s <key> 生成 detached signature
// 清理 CI 临时私钥 (无论成功失败都删) const sigArgs = ['-Sm', BUNDLE_PATH, '-s', KEY_PATH_USE]
if (KEY_PATH_TMP) { const sigR = spawnSync('minisign', sigArgs, { stdio: 'inherit' })
try { fs.unlinkSync(KEY_PATH_TMP) } catch { /* ignore */ } // 清理 CI 临时私钥 (无论成功失败都删)
KEY_PATH_TMP = null if (KEY_PATH_TMP) {
} try { fs.unlinkSync(KEY_PATH_TMP) } catch { /* ignore */ }
if (sigR.status !== 0) { KEY_PATH_TMP = null
console.error('[build-ota] minisign 签名失败') }
process.exit(1) if (sigR.status !== 0) {
} console.error('[build-ota] minisign 签名失败')
const sigPath = BUNDLE_PATH + '.minisig' process.exit(1)
signature = fs.readFileSync(sigPath, 'utf8').trim() }
const sigPath = BUNDLE_PATH + '.minisig'
// 尝试同步 pubkey 到 tauri.conf.json (如果存在) signature = fs.readFileSync(sigPath, 'utf8').trim()
const PUB_PATH = path.join(path.dirname(KEY_PATH), 'hotswap.pub')
if (fs.existsSync(PUB_PATH)) { // 尝试同步 pubkey 到 tauri.conf.json (如果存在)
pubkey = fs.readFileSync(PUB_PATH, 'utf8').trim() const PUB_PATH = path.join(path.dirname(KEY_PATH), 'hotswap.pub')
const lines = pubkey.split(/\r?\n/) if (fs.existsSync(PUB_PATH)) {
pubkey = (lines.find((l) => l.startsWith('RWR') || l.startsWith('RWT') || l.startsWith('RWQ')) || '').trim() pubkey = fs.readFileSync(PUB_PATH, 'utf8').trim()
} const lines = pubkey.split(/\r?\n/)
} pubkey = (lines.find((l) => l.startsWith('RWR') || l.startsWith('RWT') || l.startsWith('RWQ')) || '').trim()
}
// 3) 写 latest.json }
const minBinary = MIN_BINARY || (() => {
// 默认从 src-tauri/tauri.conf.json 读 version // 3) 写 latest.json
try { return JSON.parse(fs.readFileSync(TAURI_CONF, 'utf8')).version || APP_VERSION } const minBinary = MIN_BINARY || (() => {
catch { return APP_VERSION } // 默认从 src-tauri/tauri.conf.json 读 version
})() try { return JSON.parse(fs.readFileSync(TAURI_CONF, 'utf8')).version || APP_VERSION }
const manifest = { catch { return APP_VERSION }
version: APP_VERSION + '-ota', })()
sequence: SEQUENCE, const manifest = {
min_binary_version: minBinary, version: APP_VERSION + '-ota',
bundle: BUNDLE_NAME, sequence: SEQUENCE,
bundle_size: bundleStat.size, min_binary_version: minBinary,
bundle_sha256: bundleSha256, bundle: BUNDLE_NAME,
signature, bundle_size: bundleStat.size,
notes: NOTES, bundle_sha256: bundleSha256,
pub_date: new Date().toISOString(), signature,
mandatory: false, notes: String(NOTES).slice(0, 400),
// 内部用: 前端 web 模式比对 build_id commit: COMMIT || undefined,
build_id: 'seq-' + SEQUENCE, pub_date: new Date().toISOString(),
} mandatory: false,
fs.writeFileSync(path.join(OUT_DIR, 'latest.json'), JSON.stringify(manifest, null, 2)) // 内部用: 前端 web 模式比对 build_id。
// 格式与前端 vite BUILD_ID 一致 (YYYYMMDDHHmmss, 北京时间); sequence 保持毫秒单调递增
// 4) 提示 build_id: (() => {
console.log('') const d = new Date(Date.now() + 8 * 3600 * 1000)
console.log('=== OTA 包生成完成 ===') const p = (n, w) => String(n).padStart(w || 2, '0')
console.log('输出目录: ', OUT_DIR) return `${d.getUTCFullYear()}${p(d.getUTCMonth() + 1)}${p(d.getUTCDate())}${p(d.getUTCHours())}${p(d.getUTCMinutes())}${p(d.getUTCSeconds())}`
console.log('版本: ', manifest.version) })(),
console.log('sequence: ', manifest.sequence) }
console.log('bundle 大小: ', (bundleStat.size / 1024).toFixed(1) + ' KB') fs.writeFileSync(path.join(OUT_DIR, 'latest.json'), JSON.stringify(manifest, null, 2))
console.log('bundle sha256:', bundleSha256)
console.log('签名: ', signature ? '(已生成, 写入 latest.json)' : '(未签名, --no-sign)') // 4) 提示
if (pubkey) { console.log('')
console.log('') console.log('=== OTA 包生成完成 ===')
console.log('请将下面这一行 pubkey 写到 src-tauri/tauri.conf.json -> plugins.hotswap.pubkey:') console.log('输出目录: ', OUT_DIR)
console.log(' ' + pubkey) console.log('版本: ', manifest.version)
} console.log('sequence: ', manifest.sequence)
console.log('build_id: ', manifest.build_id)
console.log('') console.log('commit: ', manifest.commit || '(未指定)')
console.log('下一步: 把这两个文件发布到 OTA 存储:') console.log('bundle 大小: ', (bundleStat.size / 1024).toFixed(1) + ' KB')
console.log(' ' + BUNDLE_NAME) console.log('bundle sha256:', bundleSha256)
console.log(' latest.json') console.log('签名: ', signature ? '(已生成, 写入 latest.json)' : '(未签名, --no-sign)')
console.log('并确保 server 进程环境变量 OTA_PACKAGE_DIR 指向该目录。') if (pubkey) {
console.log('')
console.log('请将下面这一行 pubkey 写到 src-tauri/tauri.conf.json -> plugins.hotswap.pubkey:')
console.log(' ' + pubkey)
}
console.log('')
console.log('下一步: 把这两个文件发布到 OTA 存储:')
console.log(' ' + BUNDLE_NAME)
console.log(' latest.json')
console.log('并确保 server 进程环境变量 OTA_PACKAGE_DIR 指向该目录。')
+41 -36
View File
@@ -1,17 +1,24 @@
#!/usr/bin/env node #!/usr/bin/env node
// CI 专用: 校验版本号四处一致 + 与分支名一致 // CI 专用: 校验版本号分组一致 + 前端版本与分支名对应
// //
// 背景: // 背景 (2026-10-25 调整: 外壳与前端版本号解绑):
// 版本号只允许一个手工管理点 = package.json 的 version, 其余位置全部派生: // 前端 (Web/OTA) 与外壳 (Android/Windows 壳) 独立维护、独立发版,
// - APK / 桌面安装包 version = <version> (裸版本号, 无后缀) // 两组版本号不再要求相等, 只要求**组内**一致:
// - OTA 热更包 version = <version>-ota (多一个 -ota 标记) //
// - 前端运行时 APP_VERSION = <version> // 组1 前端: package.json .version
// 任何一处漏改都会让线上版本号、APK 版本、OTA 判新三者对不上, 且很难在客户端发现。 // ├─ 前端运行时 APP_VERSION = <version> (裸, 无后缀)
// └─ OTA 热更包 version = <version>-ota (打包脚本追加)
//
// 组2 外壳: src-tauri/tauri.conf.json .version (= APK versionName)
// ├─ src-tauri/Cargo.toml [package].version
// └─ src-tauri/Cargo.lock (iboard 条目)
// 外壳随 android-*/桌面发布独立演进, 与 package.json 无关。
//
// 另: package.json 的 version 不允许带 "-ota" (OTA 后缀由打包脚本追加)。
// //
// 校验分两段: // 校验分两段:
// A. 一致性 (任何分支都查): package.json ↔ tauri.conf.json ↔ Cargo.toml ↔ Cargo.lock // A. 组内一致性 (任何分支都查)
// 并要求 package.json 的 version 不含 "-ota" (裸版本, OTA 后缀由打包脚本追加) // B. 分支对应 (仅 dev-<版本> 分支, 针对前端组):
// B. 分支对应 (仅 dev-<版本> 分支):
// dev-<major>.<minor> → version 必须以 <major>.<minor> 开头 // dev-<major>.<minor> → version 必须以 <major>.<minor> 开头
// dev-<major> → version 必须以 <major>. 开头 // dev-<major> → version 必须以 <major>. 开头
// main / master / 其它 → 跳过 // main / master / 其它 → 跳过
@@ -53,61 +60,59 @@ function readCargoLockVersion() {
return null return null
} }
const sources = [ // 组1 前端 (唯一手动管理点 = package.json)
{ file: 'package.json', version: String(readJson('package.json').version || '') }, const webVersion = String(readJson('package.json').version || '')
const webSources = [{ file: 'package.json', version: webVersion }]
const otaVersion = webVersion + '-ota'
// 组2 外壳 (独立维护; 三处必须组内一致)
const shellSources = [
{ file: 'src-tauri/tauri.conf.json', version: String(readJson('src-tauri/tauri.conf.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.toml', version: readCargoTomlVersion() },
{ file: 'src-tauri/Cargo.lock (iboard)', version: readCargoLockVersion() }, { file: 'src-tauri/Cargo.lock (iboard)', version: readCargoLockVersion() },
] ]
const version = sources[0].version
const otaVersion = version + '-ota'
console.log('[check-version] 分支: ', branch || '(未知)') console.log('[check-version] 分支: ', branch || '(未知)')
for (const s of sources) { for (const s of webSources) console.log(`[check-version] [前端] ${s.file.padEnd(28)} ${s.version || '(缺失)'}`)
console.log(`[check-version] ${s.file.padEnd(28)} ${s.version || '(缺失)'}`) for (const s of shellSources) console.log(`[check-version] [外壳] ${s.file.padEnd(28)} ${s.version || '(缺失)'}`)
} console.log('[check-version] 产物版本: OTA =', otaVersion, '/ 外壳(APK versionName) =', shellSources[0].version)
console.log('[check-version] 产物版本: APK =', version, '/ OTA =', otaVersion)
let failed = false let failed = false
// ---- A. 四处一致性 + 裸版本号 ---- // ---- A1. 前端组: 裸版本号 ----
if (/-ota$/i.test(version)) { if (/-ota$/i.test(webVersion)) {
console.error('') console.error('')
console.error(`[check-version] package.json 的 version 不能带 "-ota": ${version}`) console.error(`[check-version] package.json 的 version 不能带 "-ota": ${webVersion}`)
console.error('[check-version] OTA 后缀由 scripts/build-ota.mjs 自动追加, 请只写裸版本号 (如 0.2.0)') console.error('[check-version] OTA 后缀由 scripts/build-ota.mjs 自动追加, 请只写裸版本号 (如 0.2.0)')
failed = true failed = true
} }
const mismatched = sources.filter((s) => s.version !== version) // ---- A2. 外壳组内一致 ----
if (mismatched.length) { const shellBroken = shellSources.filter((s) => s.version !== shellSources[0].version || !s.version)
if (shellBroken.length) {
console.error('') console.error('')
console.error('============================================================') console.error('============================================================')
console.error('[check-version] 版本号不一致, 构建中止') console.error('[check-version] 外壳版本号不一致 (tauri.conf.json ↔ Cargo.toml ↔ Cargo.lock), 构建中止')
console.error('============================================================') console.error('============================================================')
for (const s of mismatched) { for (const s of shellBroken) {
console.error(` ${s.file}: ${s.version || '(缺失)'} ≠ package.json: ${version}`) console.error(` ${s.file}: ${s.version || '(缺失)'} ≠ tauri.conf.json: ${shellSources[0].version}`)
} }
console.error('')
console.error(' 规则: APK / 桌面包版本 = 裸版本号 (无后缀);')
console.error(' OTA 热更包版本 = 同一个版本号 + "-ota"。')
console.error(' 改版本时四处必须一起改 (见 readme「版本管理」)。')
failed = true failed = true
} }
// ---- B. 分支对应 ---- // ---- B. 分支对应 (仅前端组; 外壳在 android-* 分支独立演进, 不参与) ----
const m = /^dev-(\d+)(?:\.(\d+))?$/.exec(branch) const m = /^dev-(\d+)(?:\.(\d+))?$/.exec(branch)
if (!m) { if (!m) {
console.log('[check-version] 非 dev-<版本> 分支, 跳过分支对应校验') console.log('[check-version] 非 dev-<版本> 分支, 跳过分支对应校验')
} else { } else {
const want = m[2] === undefined ? `${m[1]}.` : `${m[1]}.${m[2]}` const want = m[2] === undefined ? `${m[1]}.` : `${m[1]}.${m[2]}`
if (!version.startsWith(want)) { if (!webVersion.startsWith(want)) {
console.error('') console.error('')
console.error(`[check-version] 版本与分支不一致: 分支 "${branch}" 要求版本以 ${want} 开头, 实际是 ${version}`) console.error(`[check-version] 版本与分支不一致: 分支 "${branch}" 要求前端版本以 ${want} 开头, 实际是 ${webVersion}`)
console.error('[check-version] 请修改 package.json 的 version, 并同步 tauri.conf.json / Cargo.toml / Cargo.lock') console.error('[check-version] 请修改 package.json 的 version')
failed = true failed = true
} else { } else {
console.log(`[check-version] 分支一致 (${branch} ↔ ${version})`) console.log(`[check-version] 分支一致 (${branch} ↔ ${webVersion})`)
} }
} }
+345 -328
View File
@@ -1,328 +1,345 @@
import { Router } from 'express' import { Router } from 'express'
import { prisma } from '../db.js' import { prisma } from '../db.js'
import { authRequired } from '../middleware.js' import { authRequired } from '../middleware.js'
import { import {
toDateStr, addDays, periodRange, periodDays, expectedPerDay, toDateStr, addDays, periodRange, periodDays, expectedPerDay,
periodIsClosed, eachDay, PERIODS, PERIOD_LABEL, periodIsClosed, eachDay, PERIODS, PERIOD_LABEL,
} from '../checkin/period.js' } from '../checkin/period.js'
const router = Router() const router = Router()
router.use(authRequired) router.use(authRequired)
// 补打卡允许的回溯天数 (F-CK-10) // 补打卡允许的回溯天数 (F-CK-10)
const BACKFILL_DAYS = 7 const BACKFILL_DAYS = 7
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/ const DATE_RE = /^\d{4}-\d{2}-\d{2}$/
const MONTH_RE = /^\d{4}-\d{2}$/ const MONTH_RE = /^\d{4}-\d{2}$/
function normDate(v) { function normDate(v) {
const s = String(v || '') const s = String(v || '')
return DATE_RE.test(s) ? s : toDateStr() return DATE_RE.test(s) ? s : toDateStr()
} }
// ---------- 统计辅助 ---------- // ---------- 统计辅助 ----------
// 记录 -> { habitId: { date: count } } // 记录 -> { habitId: { date: count } }
function groupRecords(records) { function groupRecords(records) {
const map = new Map() const map = new Map()
for (const r of records) { for (const r of records) {
if (!map.has(r.habitId)) map.set(r.habitId, new Map()) if (!map.has(r.habitId)) map.set(r.habitId, new Map())
const byDate = map.get(r.habitId) const byDate = map.get(r.habitId)
byDate.set(r.date, (byDate.get(r.date) || 0) + 1) byDate.set(r.date, (byDate.get(r.date) || 0) + 1)
} }
return map return map
} }
function countInRange(byDate, start, end) { function countInRange(byDate, start, end) {
let n = 0 let n = 0
eachDay(start, end, (d) => { n += byDate.get(d) || 0 }) eachDay(start, end, (d) => { n += byDate.get(d) || 0 })
return n return n
} }
// 连续达标的周期数: 当前周期未结束且未达标 -> 跳过(不算断签), 不是中断 // 连续达标的周期数: 当前周期未结束且未达标 -> 跳过(不算断签), 不是中断
function computeStreak(habit, byDate, today) { function computeStreak(habit, byDate, today) {
let cursor = today let cursor = today
let streak = 0 let streak = 0
for (let i = 0; i < 400; i++) { for (let i = 0; i < 400; i++) {
const { start } = periodRange(habit.period, cursor) const { start } = periodRange(habit.period, cursor)
const done = countInRange(byDate, start, periodRange(habit.period, cursor).end) const done = countInRange(byDate, start, periodRange(habit.period, cursor).end)
if (done >= habit.target) { if (done >= habit.target) {
streak++ streak++
} else if (periodIsClosed(habit.period, cursor, today)) { } else if (periodIsClosed(habit.period, cursor, today)) {
break break
} }
const prevEnd = addDays(start, -1) const prevEnd = addDays(start, -1)
if (prevEnd < '1970-01-01') break if (prevEnd < '1970-01-01') break
cursor = prevEnd cursor = prevEnd
} }
return streak return streak
} }
async function loadActiveHabits(userId) { async function loadActiveHabits(userId) {
return prisma.habit.findMany({ return prisma.habit.findMany({
where: { userId, archived: false }, where: { userId, archived: false },
orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }], orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }],
}) })
} }
// 组装单个打卡项的展示态 // 组装单个打卡项的展示态
function buildItem(habit, byDate, date, today) { function buildItem(habit, byDate, date, today) {
const { start, end } = periodRange(habit.period, date) const { start, end } = periodRange(habit.period, date)
const done = countInRange(byDate, start, end) const done = countInRange(byDate, start, end)
const target = Math.max(1, habit.target) const target = Math.max(1, habit.target)
const onDate = byDate.get(date) || 0 const onDate = byDate.get(date) || 0
const expPerDay = expectedPerDay(habit.period, target, date) const expPerDay = expectedPerDay(habit.period, target, date)
return { return {
id: habit.id, id: habit.id,
name: habit.name, name: habit.name,
period: habit.period, period: habit.period,
periodLabel: PERIOD_LABEL[habit.period] || '每天', sortOrder: habit.sortOrder,
target, periodLabel: PERIOD_LABEL[habit.period] || '每天',
color: habit.color, target,
icon: habit.icon, color: habit.color,
note: habit.note, icon: habit.icon,
archived: habit.archived, note: habit.note,
periodStart: start, archived: habit.archived,
periodEnd: end, periodStart: start,
periodDays: periodDays(habit.period, date), periodEnd: end,
done, periodDays: periodDays(habit.period, date),
remaining: Math.max(0, target - done), done,
percent: Math.min(1, target > 0 ? done / target : 1), remaining: Math.max(0, target - done),
completed: done >= target, percent: Math.min(1, target > 0 ? done / target : 1),
over: Math.max(0, done - target), completed: done >= target,
countOnDate: onDate, over: Math.max(0, done - target),
expectedPerDay: expPerDay, countOnDate: onDate,
dayDone: onDate >= expPerDay, expectedPerDay: expPerDay,
streak: computeStreak(habit, byDate, today), dayDone: onDate >= expPerDay,
} streak: computeStreak(habit, byDate, today),
} }
}
// 拉取 [start, end] 内的打卡记录并分组
async function loadRecords(userId, habits, ranges) { // 拉取 [start, end] 内的打卡记录并分组
let min = ranges[0]?.start async function loadRecords(userId, habits, ranges) {
let max = ranges[0]?.end let min = ranges[0]?.start
for (const r of ranges) { let max = ranges[0]?.end
if (r.start < min) min = r.start for (const r of ranges) {
if (r.end > max) max = r.end 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({ if (!min || !max) return new Map()
where: { userId, habitId: { in: habits.map((h) => h.id) }, date: { gte: min, lte: max } }, const records = await prisma.checkIn.findMany({
select: { habitId: true, date: true }, where: { userId, habitId: { in: habits.map((h) => h.id) }, date: { gte: min, lte: max } },
}) select: { habitId: true, date: true },
return groupRecords(records) })
} return groupRecords(records)
}
// ---------- 打卡项 ----------
// ---------- 打卡项 ----------
// GET /api/checkin/habits?date=YYYY-MM-DD 今日(或指定日)待办 + 周期进度
router.get('/habits', async (req, res, next) => { // GET /api/checkin/habits?date=YYYY-MM-DD 今日(或指定日)待办 + 周期进度
try { router.get('/habits', async (req, res, next) => {
const userId = req.user.sub try {
const today = toDateStr() const userId = req.user.sub
const date = normDate(req.query.date) const today = toDateStr()
const habits = await loadActiveHabits(userId) const date = normDate(req.query.date)
const ranges = habits.map((h) => periodRange(h.period, date)) const habits = await loadActiveHabits(userId)
const grouped = await loadRecords(userId, habits, ranges.length ? ranges : [{ start: date, end: date }]) const ranges = habits.map((h) => periodRange(h.period, date))
const items = habits.map((h) => buildItem(h, grouped.get(h.id) || new Map(), date, today)) const grouped = await loadRecords(userId, habits, ranges.length ? ranges : [{ start: date, end: date }])
const doneCount = items.filter((i) => i.completed).length const items = habits.map((h) => buildItem(h, grouped.get(h.id) || new Map(), date, today))
res.json({ today, date, items, summary: { total: items.length, done: doneCount } }) const doneCount = items.filter((i) => i.completed).length
} catch (e) { next(e) } 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) => { // POST /api/checkin/habits 新建打卡项
try { router.post('/habits', async (req, res, next) => {
const userId = req.user.sub try {
const { name, period = 'day', target = 1, color = '#1677ff', icon = '', note = '' } = req.body || {} const userId = req.user.sub
const cleanName = String(name || '').trim() const { name, period = 'day', target = 1, color = '#1677ff', icon = '', note = '' } = req.body || {}
if (!cleanName) return res.status(400).json({ error: '名称不能为空' }) const cleanName = String(name || '').trim()
if (cleanName.length > 20) return res.status(400).json({ error: '名称不能超过 20 个字' }) if (!cleanName) return res.status(400).json({ error: '名称不能为空' })
if (!PERIODS.includes(period)) return res.status(400).json({ error: '周期类型不合法' }) if (cleanName.length > 20) return res.status(400).json({ error: '名称不能超过 20 个字' })
const t = Math.floor(Number(target)) if (!PERIODS.includes(period)) return res.status(400).json({ error: '周期类型不合法' })
if (!Number.isFinite(t) || t < 1) return res.status(400).json({ error: '目标次数需为 ≥1 的整数' }) const t = Math.floor(Number(target))
const row = await prisma.habit.create({ if (!Number.isFinite(t) || t < 1) return res.status(400).json({ error: '目标次数需为 ≥1 的整数' })
data: { userId, name: cleanName, period, target: t, color: String(color), icon: String(icon), note: String(note || '') }, const last = await prisma.habit.findFirst({ where: { userId }, orderBy: { sortOrder: 'desc' }, select: { sortOrder: true } })
}) const row = await prisma.habit.create({
res.json({ item: row }) data: { userId, name: cleanName, period, target: t, color: String(color), icon: String(icon), note: String(note || ''), sortOrder: (last?.sortOrder ?? -1) + 1 },
} catch (e) { next(e) } })
}) res.json({ item: row })
} catch (e) { next(e) }
// PUT /api/checkin/habits/:id 编辑 })
router.put('/habits/:id', async (req, res, next) => {
try { // PUT /api/checkin/habits/:id 编辑
const userId = req.user.sub router.put('/habits/:id', async (req, res, next) => {
const id = Number(req.params.id) try {
const exist = await prisma.habit.findFirst({ where: { id, userId } }) const userId = req.user.sub
if (!exist) return res.status(404).json({ error: '打卡项不存在' }) const id = Number(req.params.id)
const { name, period, target, color, icon, note, archived, sortOrder } = req.body || {} const exist = await prisma.habit.findFirst({ where: { id, userId } })
const data = {} if (!exist) return res.status(404).json({ error: '打卡项不存在' })
if (name !== undefined) { const { name, period, target, color, icon, note, archived, sortOrder } = req.body || {}
const n = String(name).trim() const data = {}
if (!n) return res.status(400).json({ error: '名称不能为空' }) if (name !== undefined) {
if (n.length > 20) return res.status(400).json({ error: '名称不能超过 20 个字' }) const n = String(name).trim()
data.name = n if (!n) return res.status(400).json({ error: '名称不能为空' })
} if (n.length > 20) return res.status(400).json({ error: '名称不能超过 20 个字' })
if (period !== undefined) { data.name = n
if (!PERIODS.includes(period)) return res.status(400).json({ error: '周期类型不合法' }) }
data.period = period if (period !== undefined) {
} if (!PERIODS.includes(period)) return res.status(400).json({ error: '周期类型不合法' })
if (target !== undefined) { data.period = period
const t = Math.floor(Number(target)) }
if (!Number.isFinite(t) || t < 1) return res.status(400).json({ error: '目标次数需为 ≥1 的整数' }) if (target !== undefined) {
data.target = t const t = Math.floor(Number(target))
} if (!Number.isFinite(t) || t < 1) return res.status(400).json({ error: '目标次数需为 ≥1 的整数' })
if (color !== undefined) data.color = String(color) data.target = t
if (icon !== undefined) data.icon = String(icon) }
if (note !== undefined) data.note = String(note) if (color !== undefined) data.color = String(color)
if (archived !== undefined) data.archived = !!archived if (icon !== undefined) data.icon = String(icon)
if (sortOrder !== undefined) data.sortOrder = Number(sortOrder) || 0 if (note !== undefined) data.note = String(note)
const row = await prisma.habit.update({ where: { id }, data }) if (archived !== undefined) data.archived = !!archived
res.json({ item: row }) if (sortOrder !== undefined) data.sortOrder = Number(sortOrder) || 0
} catch (e) { next(e) } 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 { // POST /api/checkin/habits/reorder { ids: [习惯id 的新顺序] } 按数组下标重写 sortOrder
const userId = req.user.sub router.post('/habits/reorder', async (req, res, next) => {
const id = Number(req.params.id) try {
const exist = await prisma.habit.findFirst({ where: { id, userId } }) const userId = req.user.sub
if (!exist) return res.status(404).json({ error: '打卡项不存在' }) const ids = Array.isArray((req.body || {}).ids) ? req.body.ids.map(Number).filter(Number.isFinite) : []
if (req.query.hard === '1') { if (!ids.length) return res.status(400).json({ error: 'ids 不能为空' })
await prisma.checkIn.deleteMany({ where: { habitId: id } }) const habits = await prisma.habit.findMany({ where: { userId, id: { in: ids } } })
await prisma.habit.delete({ where: { id } }) if (habits.length !== new Set(ids).size) return res.status(400).json({ error: '包含无效的打卡项' })
return res.json({ ok: true, removed: true }) await prisma.$transaction(
} ids.map((id, i) => prisma.habit.update({ where: { id }, data: { sortOrder: i } })),
const row = await prisma.habit.update({ where: { id }, data: { archived: true } }) )
res.json({ ok: true, removed: false, item: row }) res.json({ ok: true })
} catch (e) { next(e) } } catch (e) { next(e) }
}) })
// ---------- 打卡 / 撤销 ---------- // DELETE /api/checkin/habits/:id?hard=1 默认归档(保留历史), hard=1 物理删除
router.delete('/habits/:id', async (req, res, next) => {
// POST /api/checkin/habits/:id/check { date?, note? } 打一次卡 try {
router.post('/habits/:id/check', async (req, res, next) => { const userId = req.user.sub
try { const id = Number(req.params.id)
const userId = req.user.sub const exist = await prisma.habit.findFirst({ where: { id, userId } })
const id = Number(req.params.id) if (!exist) return res.status(404).json({ error: '打卡项不存在' })
const habit = await prisma.habit.findFirst({ where: { id, userId } }) if (req.query.hard === '1') {
if (!habit) return res.status(404).json({ error: '打卡项不存在' }) await prisma.checkIn.deleteMany({ where: { habitId: id } })
const today = toDateStr() await prisma.habit.delete({ where: { id } })
const date = normDate((req.body || {}).date) return res.json({ ok: true, removed: true })
if (date > today) return res.status(400).json({ error: '不能为未来日期打卡' }) }
if (date < addDays(today, -BACKFILL_DAYS)) { const row = await prisma.habit.update({ where: { id }, data: { archived: true } })
return res.status(400).json({ error: `最多只能补打最近 ${BACKFILL_DAYS} 天` }) res.json({ ok: true, removed: false, item: row })
} } catch (e) { next(e) }
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' }, // POST /api/checkin/habits/:id/check { date?, note? } 打一次卡
}) router.post('/habits/:id/check', async (req, res, next) => {
const grouped = await loadRecords(userId, [habit], [periodRange(habit.period, date)]) try {
res.json({ item: buildItem(habit, grouped.get(habit.id) || new Map(), date, today), date }) const userId = req.user.sub
} catch (e) { const id = Number(req.params.id)
// 唯一约束冲突 = 并发重复提交, 视为已打卡 const habit = await prisma.habit.findFirst({ where: { id, userId } })
if (e && e.code === 'P2002') return res.status(409).json({ error: '该次打卡已记录' }) if (!habit) return res.status(404).json({ error: '打卡项不存在' })
next(e) const today = toDateStr()
} const date = normDate((req.body || {}).date)
}) if (date > today) return res.status(400).json({ error: '不能为未来日期打卡' })
if (date < addDays(today, -BACKFILL_DAYS)) {
// DELETE /api/checkin/habits/:id/check?date= 撤销本周期内最近一次打卡 return res.status(400).json({ error: `最多只能补打最近 ${BACKFILL_DAYS} 天` })
router.delete('/habits/:id/check', async (req, res, next) => { }
try { const dayRecords = await prisma.checkIn.findMany({ where: { habitId: id, date }, select: { seq: true } })
const userId = req.user.sub const seq = dayRecords.reduce((m, r) => Math.max(m, r.seq), 0) + 1
const id = Number(req.params.id) const note = String((req.body || {}).note || '').slice(0, 200)
const habit = await prisma.habit.findFirst({ where: { id, userId } }) await prisma.checkIn.create({
if (!habit) return res.status(404).json({ error: '打卡项不存在' }) data: { habitId: id, userId, date, seq, note, source: date === today ? 'user' : 'backfill' },
const today = toDateStr() })
const date = normDate(req.query.date) const grouped = await loadRecords(userId, [habit], [periodRange(habit.period, date)])
const { start, end } = periodRange(habit.period, date) res.json({ item: buildItem(habit, grouped.get(habit.id) || new Map(), date, today), date })
const last = await prisma.checkIn.findFirst({ } catch (e) {
where: { habitId: id, date: { gte: start, lte: end } }, // 唯一约束冲突 = 并发重复提交, 视为已打卡
orderBy: [{ date: 'desc' }, { seq: 'desc' }], if (e && e.code === 'P2002') return res.status(409).json({ error: '该次打卡已记录' })
}) next(e)
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 }) // DELETE /api/checkin/habits/:id/check?date= 撤销本周期内最近一次打卡
} catch (e) { next(e) } 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 } })
// GET /api/checkin/day?date=YYYY-MM-DD 当天每个打卡项的明细 (含可打卡/只读标记) if (!habit) return res.status(404).json({ error: '打卡项不存在' })
router.get('/day', async (req, res, next) => { const today = toDateStr()
try { const date = normDate(req.query.date)
const userId = req.user.sub const { start, end } = periodRange(habit.period, date)
const today = toDateStr() const last = await prisma.checkIn.findFirst({
const date = normDate(req.query.date) where: { habitId: id, date: { gte: start, lte: end } },
const habits = await loadActiveHabits(userId) orderBy: [{ date: 'desc' }, { seq: 'desc' }],
const ranges = habits.map((h) => periodRange(h.period, date)) })
const grouped = await loadRecords(userId, habits, ranges.length ? ranges : [{ start: date, end: date }]) if (!last) return res.status(400).json({ error: '本周期还没有打卡记录' })
const items = habits.map((h) => { await prisma.checkIn.delete({ where: { id: last.id } })
const base = buildItem(h, grouped.get(h.id) || new Map(), date, today) const grouped = await loadRecords(userId, [habit], [{ start, end }])
return { ...base, canCheck: date <= today && date >= addDays(today, -BACKFILL_DAYS) } res.json({ item: buildItem(habit, grouped.get(habit.id) || new Map(), date, today), date })
}) } catch (e) { next(e) }
res.json({ today, date, isToday: date === today, isFuture: date > today, items }) })
} catch (e) { next(e) }
}) // ---------- 某日详情 / 月历 ----------
// GET /api/checkin/calendar?month=YYYY-MM 月历: 每天完成情况 + 当月完成率 // GET /api/checkin/day?date=YYYY-MM-DD 当天每个打卡项的明细 (含可打卡/只读标记)
router.get('/calendar', async (req, res, next) => { router.get('/day', async (req, res, next) => {
try { try {
const userId = req.user.sub const userId = req.user.sub
const today = toDateStr() const today = toDateStr()
const month = MONTH_RE.test(String(req.query.month || '')) ? String(req.query.month) : today.slice(0, 7) const date = normDate(req.query.date)
const start = month + '-01' const habits = await loadActiveHabits(userId)
const end = addDays(monthStartNext(month), -1) const ranges = habits.map((h) => periodRange(h.period, date))
const habits = await prisma.habit.findMany({ where: { userId }, orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }] }) const grouped = await loadRecords(userId, habits, ranges.length ? ranges : [{ start: date, end: date }])
const active = habits.filter((h) => !h.archived) const items = habits.map((h) => {
const records = await prisma.checkIn.findMany({ const base = buildItem(h, grouped.get(h.id) || new Map(), date, today)
where: { userId, date: { gte: start, lte: end } }, return { ...base, canCheck: date <= today && date >= addDays(today, -BACKFILL_DAYS) }
select: { habitId: true, date: true }, })
}) res.json({ today, date, isToday: date === today, isFuture: date > today, items })
const grouped = groupRecords(records) } catch (e) { next(e) }
const days = [] })
let expTotal = 0
let doneTotal = 0 // GET /api/checkin/calendar?month=YYYY-MM 月历: 每天完成情况 + 当月完成率
eachDay(start, end, (d) => { router.get('/calendar', async (req, res, next) => {
let expected = 0 try {
let actual = 0 const userId = req.user.sub
let over = false const today = toDateStr()
const detail = [] const month = MONTH_RE.test(String(req.query.month || '')) ? String(req.query.month) : today.slice(0, 7)
for (const h of active) { const start = month + '-01'
if (toDateStr(h.createdAt) > d) continue // 创建之前的日期不计入期望 const end = addDays(monthStartNext(month), -1)
const exp = expectedPerDay(h.period, Math.max(1, h.target), d) const habits = await prisma.habit.findMany({ where: { userId }, orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }] })
const cnt = (grouped.get(h.id) || new Map()).get(d) || 0 const active = habits.filter((h) => !h.archived)
expected += exp const records = await prisma.checkIn.findMany({
actual += Math.min(cnt, exp) where: { userId, date: { gte: start, lte: end } },
if (cnt > exp) over = true select: { habitId: true, date: true },
detail.push({ id: h.id, name: h.name, color: h.color, count: cnt, expected: exp, done: cnt >= exp }) })
} const grouped = groupRecords(records)
const isFuture = d > today const days = []
if (!isFuture) { expTotal += expected; doneTotal += actual } let expTotal = 0
let status = 'empty' let doneTotal = 0
if (expected > 0 && actual >= expected) status = over ? 'over' : 'full' eachDay(start, end, (d) => {
else if (actual > 0) status = 'partial' let expected = 0
days.push({ let actual = 0
date: d, expected, actual, status, isToday: d === today, isFuture, let over = false
habits: detail, const detail = []
}) for (const h of active) {
}) if (toDateStr(h.createdAt) > d) continue // 创建之前的日期不计入期望
res.json({ const exp = expectedPerDay(h.period, Math.max(1, h.target), d)
today, month, days, const cnt = (grouped.get(h.id) || new Map()).get(d) || 0
rate: expTotal > 0 ? Math.min(1, doneTotal / expTotal) : 0, expected += exp
habits: active.map((h) => ({ id: h.id, name: h.name, color: h.color, period: h.period, target: h.target })), actual += Math.min(cnt, exp)
}) if (cnt > exp) over = true
} catch (e) { next(e) } detail.push({ id: h.id, name: h.name, color: h.color, count: cnt, expected: exp, done: cnt >= exp })
}) }
const isFuture = d > today
function monthStartNext(month) { if (!isFuture) { expTotal += expected; doneTotal += actual }
const [y, m] = month.split('-').map(Number) let status = 'empty'
const d = new Date(Date.UTC(y, m, 1)) // m 已 +1 (下月) if (expected > 0 && actual >= expected) status = over ? 'over' : 'full'
return d.toISOString().slice(0, 10) else if (actual > 0) status = 'partial'
} days.push({
date: d, expected, actual, status, isToday: d === today, isFuture,
export default router 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
+105 -102
View File
@@ -1,102 +1,105 @@
// OTA 升级接口 (兼容 tauri-plugin-hotswap 服务端约定) // OTA 升级接口 (兼容 tauri-plugin-hotswap 服务端约定)
// GET /api/ota/check/:current_sequence // GET /api/ota/check/:current_sequence
// - 客户端传当前 sequence, 服务端判断是否有更新 // - 客户端传当前 sequence, 服务端判断是否有更新
// - 有: 200 + manifest JSON // - 有: 200 + manifest JSON
// - 无: 204 No Content // - 无: 204 No Content
// GET /api/ota/bundle/:filename // GET /api/ota/bundle/:filename
// - 下载前端 bundle (tar.gz), 供 manifest.url 引用 // - 下载前端 bundle (tar.gz), 供 manifest.url 引用
// GET /api/ota/manifest-web // GET /api/ota/manifest-web
// - 给 Web 浏览器用的简化版 manifest (无签名, 仅展示用) // - 给 Web 浏览器用的简化版 manifest (无签名, 仅展示用)
// //
// 数据源: 环境变量 OTA_PACKAGE_DIR 指向的目录, 里面要放: // 数据源: 环境变量 OTA_PACKAGE_DIR 指向的目录, 里面要放:
// - latest.json (由 scripts/build-ota.mjs 生成) // - latest.json (由 scripts/build-ota.mjs 生成)
// { sequence, version, min_binary_version, signature, notes, bundle, mandatory, pub_date, bundle_size } // { sequence, version, min_binary_version, signature, notes, bundle, mandatory, pub_date, bundle_size }
// - <filename> (tar.gz 升级包) // - <filename> (tar.gz 升级包)
import { Router } from 'express' import { Router } from 'express'
import fs from 'node:fs' import fs from 'node:fs'
import path from 'node:path' import path from 'node:path'
const router = Router() const router = Router()
function resolveOtaDir() { function resolveOtaDir() {
const dir = process.env.OTA_PACKAGE_DIR const dir = process.env.OTA_PACKAGE_DIR
? path.resolve(process.env.OTA_PACKAGE_DIR) ? path.resolve(process.env.OTA_PACKAGE_DIR)
: path.resolve(process.cwd(), 'ota-package') : path.resolve(process.cwd(), 'ota-package')
return dir return dir
} }
function readLatest() { function readLatest() {
const p = path.join(resolveOtaDir(), 'latest.json') const p = path.join(resolveOtaDir(), 'latest.json')
if (!fs.existsSync(p)) return null if (!fs.existsSync(p)) return null
try { try {
return JSON.parse(fs.readFileSync(p, 'utf8')) return JSON.parse(fs.readFileSync(p, 'utf8'))
} catch (e) { } catch (e) {
console.error('[ota] latest.json 解析失败:', e.message) console.error('[ota] latest.json 解析失败:', e.message)
return null return null
} }
} }
// GET /api/ota/check/:current_sequence // GET /api/ota/check/:current_sequence
// 客户端实际请求 URL 是 endpoint 中 {{current_sequence}} 替换后的形式 // 客户端实际请求 URL 是 endpoint 中 {{current_sequence}} 替换后的形式
router.get('/ota/check/:current_sequence', (req, res) => { router.get('/ota/check/:current_sequence', (req, res) => {
const currentSeq = Number(req.params.current_sequence) const currentSeq = Number(req.params.current_sequence)
if (!Number.isFinite(currentSeq) || currentSeq < 0) { if (!Number.isFinite(currentSeq) || currentSeq < 0) {
return res.status(400).json({ error: '无效的 sequence' }) return res.status(400).json({ error: '无效的 sequence' })
} }
const latest = readLatest() const latest = readLatest()
if (!latest) { if (!latest) {
// 无可用包, 按约定返回 204 // 无可用包, 按约定返回 204
return res.status(204).end() return res.status(204).end()
} }
// 不新于当前则 204 // 不新于当前则 204
if (typeof latest.sequence === 'number' && latest.sequence <= currentSeq) { if (typeof latest.sequence === 'number' && latest.sequence <= currentSeq) {
return res.status(204).end() return res.status(204).end()
} }
// 检查 binary_version 兼容性 (客户端会再校验一次, 这里只做参考过滤) // 检查 binary_version 兼容性 (客户端会再校验一次, 这里只做参考过滤)
// 客户端传 binary_version query: 暂不强制, 让客户端兜底 // 客户端传 binary_version query: 暂不强制, 让客户端兜底
const dir = resolveOtaDir() const dir = resolveOtaDir()
const base = process.env.OTA_PUBLIC_BASE || '' const base = process.env.OTA_PUBLIC_BASE || ''
res.json({ res.json({
version: latest.version, version: latest.version,
sequence: latest.sequence, sequence: latest.sequence,
min_binary_version: latest.min_binary_version || '0.0.0', min_binary_version: latest.min_binary_version || '0.0.0',
url: (base ? base : '') + '/api/ota/bundle/' + encodeURIComponent(latest.bundle), url: (base ? base : '') + '/api/ota/bundle/' + encodeURIComponent(latest.bundle),
signature: latest.signature, signature: latest.signature,
notes: latest.notes || '', notes: latest.notes || '',
pub_date: latest.pub_date || new Date().toISOString(), commit: latest.commit || '',
mandatory: !!latest.mandatory, pub_date: latest.pub_date || new Date().toISOString(),
bundle_size: latest.bundle_size || 0, mandatory: !!latest.mandatory,
}) bundle_size: latest.bundle_size || 0,
}) })
})
// GET /api/ota/bundle/:filename - 流式下载 bundle
router.get('/ota/bundle/:filename', (req, res) => { // GET /api/ota/bundle/:filename - 流式下载 bundle
const dir = resolveOtaDir() router.get('/ota/bundle/:filename', (req, res) => {
// 防越界: 只取 basename const dir = resolveOtaDir()
const name = path.basename(req.params.filename) // 防越界: 只取 basename
const p = path.join(dir, name) const name = path.basename(req.params.filename)
if (!fs.existsSync(p)) return res.status(404).json({ error: 'bundle 不存在' }) const p = path.join(dir, name)
const stat = fs.statSync(p) if (!fs.existsSync(p)) return res.status(404).json({ error: 'bundle 不存在' })
res.setHeader('Content-Type', 'application/gzip') const stat = fs.statSync(p)
res.setHeader('Content-Length', stat.size) res.setHeader('Content-Type', 'application/gzip')
res.setHeader('Content-Disposition', 'attachment; filename="' + name + '"') res.setHeader('Content-Length', stat.size)
fs.createReadStream(p).pipe(res) res.setHeader('Content-Disposition', 'attachment; filename="' + name + '"')
}) fs.createReadStream(p).pipe(res)
})
// GET /api/ota/manifest-web - Web 浏览器用的简化版
// 客户端会拿这个跟当前 build_id 比较, 有新版本就提示用户刷新 // GET /api/ota/manifest-web - Web 浏览器用的简化版
router.get('/ota/manifest-web', (_req, res) => { // 客户端会拿这个跟当前 build_id 比较, 有新版本就提示用户刷新
const latest = readLatest() router.get('/ota/manifest-web', (_req, res) => {
if (!latest) return res.json({ available: false }) const latest = readLatest()
res.json({ if (!latest) return res.json({ available: false })
available: true, res.json({
version: latest.version, available: true,
build_id: latest.build_id || latest.version, version: latest.version,
note: latest.notes || '', sequence: latest.sequence,
size: latest.bundle_size || 0, commit: latest.commit || '',
}) build_id: latest.build_id || latest.version,
}) note: latest.notes || '',
size: latest.bundle_size || 0,
// GET /api/ota/info 已删除: 诊断接口会暴露服务器路径与签名, 且无调用方 })
})
export default router
// GET /api/ota/info 已删除: 诊断接口会暴露服务器路径与签名, 且无调用方
export default router
+33 -1
View File
@@ -38,6 +38,20 @@ export async function ensurePersonalProject(userId) {
return project return project
} }
// 任务类型标签: 项目级自定义, 未配置时用默认四类
export const DEFAULT_TASK_KINDS = ['需求', 'Bug', 'FEATURE', '优化']
export function normTaskKinds(v) {
if (!Array.isArray(v)) return null
const out = []
for (const k of v) {
const s = String(k || '').trim().slice(0, 20)
if (s && !out.includes(s)) out.push(s)
if (out.length >= 30) break
}
return out
}
function projectSummary(p) { function projectSummary(p) {
return { return {
id: p.id, id: p.id,
@@ -47,6 +61,8 @@ function projectSummary(p) {
archived: p.archived, archived: p.archived,
createdAt: p.createdAt, createdAt: p.createdAt,
updatedAt: p.updatedAt, updatedAt: p.updatedAt,
taskKinds: normTaskKinds(p.taskKinds) || DEFAULT_TASK_KINDS,
icon: p.icon || null,
memberCount: p._count?.members ?? undefined, memberCount: p._count?.members ?? undefined,
taskCount: p._count?.tasks ?? undefined, taskCount: p._count?.tasks ?? undefined,
} }
@@ -119,7 +135,7 @@ router.put('/:id', async (req, res, next) => {
const userId = req.user.sub const userId = req.user.sub
const project = await loadProjectForUser(req.params.id, userId) const project = await loadProjectForUser(req.params.id, userId)
if (!project) return res.status(404).json({ error: '项目不存在或无权限' }) if (!project) return res.status(404).json({ error: '项目不存在或无权限' })
const { name, archived, notes } = req.body || {} const { name, archived, notes, taskKinds, icon } = req.body || {}
const data = {} const data = {}
if (name !== undefined) { if (name !== undefined) {
const t = String(name).trim() const t = String(name).trim()
@@ -127,6 +143,22 @@ router.put('/:id', async (req, res, next) => {
if (t.length > MAX_NAME) return res.status(400).json({ error: `项目名称不能超过 ${MAX_NAME} 字` }) if (t.length > MAX_NAME) return res.status(400).json({ error: `项目名称不能超过 ${MAX_NAME} 字` })
data.name = t data.name = t
} }
// 任务类型标签: 整体替换; 空数组回落默认
if (taskKinds !== undefined) {
if (!Array.isArray(taskKinds)) return res.status(400).json({ error: '任务类型格式非法' })
const kinds = normTaskKinds(taskKinds)
if (!kinds?.length) return res.status(400).json({ error: '至少保留一个任务类型' })
data.taskKinds = kinds
}
// 项目图标: data URL (客户端已裁剪), null 清除回退首字色块
if (icon !== undefined && icon !== null) {
const s = String(icon)
if (!s.startsWith('data:image/')) return res.status(400).json({ error: '图标格式非法' })
if (s.length > 300_000) return res.status(400).json({ error: '图标过大' })
data.icon = s
} else if (icon === null) {
data.icon = null
}
if (archived !== undefined) data.archived = !!archived if (archived !== undefined) data.archived = !!archived
// 项目信息块 (T-PJ-13): 任意多个 { title, content }, 整体替换 // 项目信息块 (T-PJ-13): 任意多个 { title, content }, 整体替换
if (notes !== undefined) { if (notes !== undefined) {
+46 -3
View File
@@ -70,10 +70,11 @@ router.post('/projects/:id/tasks', async (req, res, next) => {
const project = await loadProjectForUser(req.params.id, userId) const project = await loadProjectForUser(req.params.id, userId)
if (!project) return res.status(404).json({ error: '项目不存在或无权限' }) if (!project) return res.status(404).json({ error: '项目不存在或无权限' })
const { title, description = '', parentId, assigneeId, memberIds = [], tags = [] } = req.body || {} const { title, description = '', parentId, assigneeId, memberIds = [], tags = [], kind, markDate } = req.body || {}
const cleanTitle = String(title || '').trim() const cleanTitle = String(title || '').trim()
if (!cleanTitle) return res.status(400).json({ error: '任务标题不能为空' }) if (!cleanTitle) return res.status(400).json({ error: '任务标题不能为空' })
if (cleanTitle.length > 200) return res.status(400).json({ error: '任务标题不能超过 200 字' }) if (cleanTitle.length > 30) return res.status(400).json({ error: '任务标题不能超过 30 字' })
const mark = parseMarkDate(markDate)
const columns = await loadColumns(project.id) const columns = await loadColumns(project.id)
if (!columns.length) return res.status(400).json({ error: '项目没有可用的看板列' }) if (!columns.length) return res.status(400).json({ error: '项目没有可用的看板列' })
@@ -107,6 +108,8 @@ router.post('/projects/:id/tasks', async (req, res, next) => {
parentId: pid, parentId: pid,
title: cleanTitle, title: cleanTitle,
description: String(description || ''), description: String(description || ''),
kind: kind ? String(kind).trim().slice(0, 20) : null,
markDate: mark,
status: firstCol.boundStatus || 'todo', status: firstCol.boundStatus || 'todo',
assigneeId: finalAssignee, assigneeId: finalAssignee,
creatorId: userId, creatorId: userId,
@@ -124,6 +127,27 @@ router.post('/projects/:id/tasks', async (req, res, next) => {
} catch (e) { next(e) } } catch (e) { next(e) }
}) })
// POST /api/projects/:id/tasks/reorder { ids: [顶层任务 id 的新顺序] }
// 按数组下标重写 sortOrder (组内拖动排序; 子任务不参与, 跟随父任务)
router.post('/projects/:id/tasks/reorder', 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).filter(Number.isFinite) : []
if (!ids.length) return res.status(400).json({ error: 'ids 不能为空' })
const rows = await prisma.task.findMany({
where: { projectId: project.id, id: { in: ids }, parentId: null },
select: { id: true },
})
if (rows.length !== new Set(ids).size) return res.status(400).json({ error: '包含无效的任务' })
await prisma.$transaction(
ids.map((id, i) => prisma.task.update({ where: { id }, data: { sortOrder: i } })),
)
res.json({ ok: true })
} catch (e) { next(e) }
})
// ---------- 单条任务 ---------- // ---------- 单条任务 ----------
// GET /api/tasks/:id // GET /api/tasks/:id
@@ -136,6 +160,14 @@ router.get('/tasks/:id', async (req, res, next) => {
} catch (e) { next(e) } } catch (e) { next(e) }
}) })
// 时间标记: 接受 YYYY-MM-DD (或完整 ISO), 存 DateTime; 非法输入 400 由调用方处理
function parseMarkDate(v) {
if (v === null || v === undefined || v === '') return null
const d = new Date(v)
if (Number.isNaN(d.getTime())) return undefined // 非法
return d
}
// PUT /api/tasks/:id 关键: 列/状态联动裁决 // PUT /api/tasks/:id 关键: 列/状态联动裁决
router.put('/tasks/:id', async (req, res, next) => { router.put('/tasks/:id', async (req, res, next) => {
try { try {
@@ -152,10 +184,21 @@ router.put('/tasks/:id', async (req, res, next) => {
if (body.title !== undefined) { if (body.title !== undefined) {
const t = String(body.title).trim() const t = String(body.title).trim()
if (!t) return res.status(400).json({ error: '任务标题不能为空' }) if (!t) return res.status(400).json({ error: '任务标题不能为空' })
if (t.length > 200) return res.status(400).json({ error: '任务标题不能超过 200 字' }) if (t.length > 30) return res.status(400).json({ error: '任务标题不能超过 30 字' })
data.title = t data.title = t
} }
if (body.description !== undefined) data.description = String(body.description) if (body.description !== undefined) data.description = String(body.description)
// 任务类型标签: null 清空, 否则截断保存
if (body.kind !== undefined) {
const k = String(body.kind || '').trim().slice(0, 20)
data.kind = k || null
}
// 时间标记: 完整日期存储
if (body.markDate !== undefined) {
const mark = parseMarkDate(body.markDate)
if (mark === undefined) return res.status(400).json({ error: '时间标记格式非法' })
data.markDate = mark
}
if (body.extras !== undefined) { if (body.extras !== undefined) {
if (!Array.isArray(body.extras)) return res.status(400).json({ error: '扩展信息格式非法' }) if (!Array.isArray(body.extras)) return res.status(400).json({ error: '扩展信息格式非法' })
data.extras = body.extras.map((e) => ({ data.extras = body.extras.map((e) => ({
-3
View File
@@ -8,7 +8,6 @@ import MePage from './pages/MePage.jsx'
import CheckinPage from './pages/checkin/CheckinPage.jsx' import CheckinPage from './pages/checkin/CheckinPage.jsx'
import TaskPage from './pages/task/TaskPage.jsx' import TaskPage from './pages/task/TaskPage.jsx'
import { useAuth } from './auth/AuthContext.jsx' import { useAuth } from './auth/AuthContext.jsx'
import useKeyboardInsetWatcher from './hooks/useKeyboardInsetWatcher.js'
// 默认落地页: 打卡 (V0.2 起打卡是第一个 Tab 且为默认选中) // 默认落地页: 打卡 (V0.2 起打卡是第一个 Tab 且为默认选中)
const HOME_PATH = '/checkin' const HOME_PATH = '/checkin'
@@ -31,8 +30,6 @@ function RequireAuth({ children }) {
} }
export default function App() { export default function App() {
// 键盘插入量全局监听: 写入 --kb-inset, 弹层用 .kb-safe 类即可避让键盘 (重点设计 §7.2)
useKeyboardInsetWatcher()
return ( return (
<Routes> <Routes>
<Route path="/login" element={<RedirectIfAuthed><LoginPage /></RedirectIfAuthed>} /> <Route path="/login" element={<RedirectIfAuthed><LoginPage /></RedirectIfAuthed>} />
+42 -41
View File
@@ -1,41 +1,42 @@
import { api } from './client.js' import { api } from './client.js'
const q = (params) => { const q = (params) => {
const s = new URLSearchParams() const s = new URLSearchParams()
Object.entries(params || {}).forEach(([k, v]) => { Object.entries(params || {}).forEach(([k, v]) => {
if (v !== undefined && v !== null && v !== '') s.set(k, v) if (v !== undefined && v !== null && v !== '') s.set(k, v)
}) })
const str = s.toString() const str = s.toString()
return str ? '?' + str : '' return str ? '?' + str : ''
} }
// 打卡 / 日历 // 打卡 / 日历
export const checkinApi = { export const checkinApi = {
habits: (date) => api('/checkin/habits' + q({ date })), habits: (date) => api('/checkin/habits' + q({ date })),
day: (date) => api('/checkin/day' + q({ date })), day: (date) => api('/checkin/day' + q({ date })),
calendar: (month) => api('/checkin/calendar' + q({ month })), calendar: (month) => api('/checkin/calendar' + q({ month })),
createHabit: (body) => api('/checkin/habits', { method: 'POST', body }), createHabit: (body) => api('/checkin/habits', { method: 'POST', body }),
updateHabit: (id, body) => api(`/checkin/habits/${id}`, { method: 'PUT', body }), updateHabit: (id, body) => api(`/checkin/habits/${id}`, { method: 'PUT', body }),
deleteHabit: (id, hard) => api(`/checkin/habits/${id}${hard ? '?hard=1' : ''}`, { method: 'DELETE' }), reorderHabits: (ids) => api('/checkin/habits/reorder', { method: 'POST', body: { ids } }),
check: (id, date, note) => api(`/checkin/habits/${id}/check`, { method: 'POST', body: { date, note } }), deleteHabit: (id, hard) => api(`/checkin/habits/${id}${hard ? '?hard=1' : ''}`, { method: 'DELETE' }),
uncheck: (id, date) => api(`/checkin/habits/${id}/check` + q({ date }), { 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 })), export const checklistApi = {
list: (id) => api(`/checklists/${id}`), lists: (archived) => api('/checklists' + q({ archived: archived ? 1 : 0 })),
create: (body) => api('/checklists', { method: 'POST', body }), list: (id) => api(`/checklists/${id}`),
update: (id, body) => api(`/checklists/${id}`, { method: 'PUT', body }), create: (body) => api('/checklists', { method: 'POST', body }),
remove: (id) => api(`/checklists/${id}`, { method: 'DELETE' }), update: (id, body) => api(`/checklists/${id}`, { method: 'PUT', body }),
duplicate: (id) => api(`/checklists/${id}/duplicate`, { method: 'POST', body: {} }), remove: (id) => api(`/checklists/${id}`, { method: 'DELETE' }),
addItem: (id, body) => api(`/checklists/${id}/items`, { method: 'POST', body }), duplicate: (id) => api(`/checklists/${id}/duplicate`, { method: 'POST', body: {} }),
updateItem: (itemId, body) => api(`/checklists/items/${itemId}`, { method: 'PUT', body }), addItem: (id, body) => api(`/checklists/${id}/items`, { method: 'POST', body }),
removeItem: (itemId) => api(`/checklists/items/${itemId}`, { method: 'DELETE' }), updateItem: (itemId, body) => api(`/checklists/items/${itemId}`, { method: 'PUT', body }),
checkAll: (id, done) => api(`/checklists/${id}/check-all`, { method: 'POST', body: { done } }), removeItem: (itemId) => api(`/checklists/items/${itemId}`, { method: 'DELETE' }),
templates: () => api('/checklists/templates'), checkAll: (id, done) => api(`/checklists/${id}/check-all`, { method: 'POST', body: { done } }),
template: (id) => api(`/checklists/templates/${id}`), templates: () => api('/checklists/templates'),
createTemplate: (body) => api('/checklists/templates', { method: 'POST', body }), template: (id) => api(`/checklists/templates/${id}`),
updateTemplate: (id, body) => api(`/checklists/templates/${id}`, { method: 'PUT', body }), createTemplate: (body) => api('/checklists/templates', { method: 'POST', body }),
removeTemplate: (id) => api(`/checklists/templates/${id}`, { method: 'DELETE' }), updateTemplate: (id, body) => api(`/checklists/templates/${id}`, { method: 'PUT', body }),
} removeTemplate: (id) => api(`/checklists/templates/${id}`, { method: 'DELETE' }),
}
+278 -259
View File
@@ -1,259 +1,278 @@
// 前端 OTA 封装 // 前端 OTA 封装
// - Tauri 客户端: 直接用 tauri-plugin-hotswap-api (checkUpdate / applyUpdate / ...) // - Tauri 客户端: 直接用 tauri-plugin-hotswap-api (checkUpdate / applyUpdate / ...)
// - Web 浏览器: 只走我们自己的 /api/ota/manifest, 提示用户手动刷新 (Vite 资源指纹会失效) // - Web 浏览器: 只走我们自己的 /api/ota/manifest, 提示用户手动刷新 (Vite 资源指纹会失效)
// //
// 设计原则: // 设计原则:
// - Web 端只能"查 + 提示", 不能真下载应用, 避免误导 // - Web 端只能"查 + 提示", 不能真下载应用, 避免误导
// - Tauri 端走 hotswap 插件的官方 API, 签名校验/回滚/资源替换全交给插件 // - Tauri 端走 hotswap 插件的官方 API, 签名校验/回滚/资源替换全交给插件
import { getApiBase } from './client.js' import { getApiBase } from './client.js'
import { APP_VERSION, BUILD_ID } from '../version.js' import { APP_VERSION, BUILD_ID } from '../version.js'
// 判断是否在 Tauri WebView 内 (有 __TAURI_INTERNALS__ 即 Tauri 环境) // 判断是否在 Tauri WebView 内 (有 __TAURI_INTERNALS__ 即 Tauri 环境)
const inTauri = typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window const inTauri = typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window
// 拼接绝对 URL: 在 Tauri (VITE_API_BASE 有值) 时直连, 否则同源 // 拼接绝对 URL: 在 Tauri (VITE_API_BASE 有值) 时直连, 否则同源
function resolveUrl(p) { function resolveUrl(p) {
const base = getApiBase() const base = getApiBase()
return base ? base + p : p return base ? base + p : p
} }
// 当前版本信息 (前端展示用) // 当前版本信息 (前端展示用)
export function getCurrentVersionInfo() { export function getCurrentVersionInfo() {
return { version: APP_VERSION, build: BUILD_ID } return { version: APP_VERSION, build: BUILD_ID }
} }
// 启动时: 确认当前资源可用 (Tauri only) // 构建标识统一化: 任意来源 (YYYYMMDDHHmmss / seq-<ms> / 毫秒数) → 'YYYYMMDDHHmmss'
// 失败/异常都不抛, 仅 console.warn // (北京时间, 与 vite BUILD_ID / OTA manifest.build_id 同格式; 无法解析返回 '')
export function initOta() { export function fmtBuildId(v) {
if (!inTauri) return if (v == null) return ''
// 动态 import: web 模式根本不打这个 chunk if (typeof v === 'string' && v.startsWith('seq-')) return fmtBuildId(Number(v.slice(4)))
import('tauri-plugin-hotswap-api') if (typeof v === 'string' && /^\d{14}$/.test(v)) return v
.then((mod) => mod.notifyReady()) const ms = Number(v)
.then(() => console.log('[ota] notifyReady ok')) if (!Number.isFinite(ms) || ms <= 0) return ''
.catch((e) => console.warn('[ota] notifyReady failed:', e)) const d = new Date(ms + 8 * 3600 * 1000)
} const p = (n, w) => String(n).padStart(w || 2, '0')
return `${d.getUTCFullYear()}${p(d.getUTCMonth() + 1)}${p(d.getUTCDate())}${p(d.getUTCHours())}${p(d.getUTCMinutes())}${p(d.getUTCSeconds())}`
// --------------------------------------------------------------------------- }
// 判新模式 (V0.2)
// 「开发版本」开关 (我的页 OTA 组内, 默认关闭), 值持久化在设备端 localStorage: // 启动时: 确认当前资源可用 (Tauri only)
// 开关 关闭 (默认 / 生产口径): 用**版本号**判新 —— 取服务端 manifest 的 version, // 失败/异常都不抛, 仅 console.warn
// 与当前运行版本按数字字段逐段比较大小 (0.2.0 > 0.1.0)。 export function initOta() {
// 这样即使某个旧版本分支被重新构建 (sequence 变大但版本号更小), 也不会把新设备拉回旧版。 if (!inTauri) return
// 开关 打开 (开发口径): 用 **build sequence** 判新 —— 直接走插件 checkUpdate(), // 动态 import: web 模式根本不打这个 chunk
// 只要服务端 sequence 比本地大就算新, 每次提交都能推到设备上, 便于联调。 import('tauri-plugin-hotswap-api')
// --------------------------------------------------------------------------- .then((mod) => mod.notifyReady())
const OTA_DEV_MODE_KEY = 'iboard_ota_dev_mode' .then(() => console.log('[ota] notifyReady ok'))
.catch((e) => console.warn('[ota] notifyReady failed:', e))
export function getOtaDevMode() { }
try {
return localStorage.getItem(OTA_DEV_MODE_KEY) === '1' // ---------------------------------------------------------------------------
} catch { /* ignore */ } // 判新模式 (V0.2)
return false // 「开发版本」开关 (我的页 OTA 组内, 默认关闭), 值持久化在设备端 localStorage:
} // 开关 关闭 (默认 / 生产口径): 用**版本号**判新 —— 取服务端 manifest 的 version,
// 与当前运行版本按数字字段逐段比较大小 (0.2.0 > 0.1.0)。
export function setOtaDevMode(v) { // 这样即使某个旧版本分支被重新构建 (sequence 变大但版本号更小), 也不会把新设备拉回旧版。
try { localStorage.setItem(OTA_DEV_MODE_KEY, v ? '1' : '0') } catch { /* ignore */ } // 开关 打开 (开发口径): 用 **build sequence** 判新 —— 直接走插件 checkUpdate(),
} // 只要服务端 sequence 比本地大就算新, 每次提交都能推到设备上, 便于联调。
// ---------------------------------------------------------------------------
// 版本号 → 数字字段数组 / 比较函数 (纯函数放在 utils, 便于单测) const OTA_DEV_MODE_KEY = 'iboard_ota_dev_mode'
import { compareVersion, parseVersionFields } from '../utils/version.js'
export function getOtaDevMode() {
export { compareVersion, parseVersionFields } try {
return localStorage.getItem(OTA_DEV_MODE_KEY) === '1'
// 拉服务端最新 manifest (不分平台) } catch { /* ignore */ }
// /api/ota/check/0: current_sequence 传 0, 只要有 OTA 包就一定返回 200 + manifest return false
export async function fetchOtaManifest() { }
try {
const res = await fetch(resolveUrl('/api/ota/check/0'), { method: 'GET' }) export function setOtaDevMode(v) {
if (res.status === 204) return null try { localStorage.setItem(OTA_DEV_MODE_KEY, v ? '1' : '0') } catch { /* ignore */ }
if (!res.ok) return null }
return await res.json()
} catch (e) { // 版本号 → 数字字段数组 / 比较函数 (纯函数放在 utils, 便于单测)
console.warn('[ota] manifest 拉取失败:', e) import { compareVersion, parseVersionFields } from '../utils/version.js'
return null
} export { compareVersion, parseVersionFields }
}
// 拉服务端最新 manifest (不分平台)
// 检查更新 // /api/ota/check/0: current_sequence 传 0, 只要有 OTA 包就一定返回 200 + manifest
// 返回统一结构, 调用方不关心是 Tauri 还是 web: export async function fetchOtaManifest() {
// { try {
// available: boolean, const res = await fetch(resolveUrl('/api/ota/check/0'), { method: 'GET' })
// reason: 'no_package' | 'same_version' | 'ota_disabled' | 'unsupported' | 'ok', if (res.status === 204) return null
// info: { version, sequence, notes, mandatory, bundle_size } if (!res.ok) return null
// } return await res.json()
// opts.devMode: true=按 build sequence 判新, false=按版本号判新 (默认) } catch (e) {
export async function checkOtaUpdate(opts) { console.warn('[ota] manifest 拉取失败:', e)
const devMode = !!(opts && opts.devMode) return null
if (!inTauri) { }
return checkOtaUpdateWeb(devMode) }
}
return checkOtaUpdateTauri(devMode) // 检查更新
} // 返回统一结构, 调用方不关心是 Tauri 还是 web:
// {
// 取"当前运行版本": 已应用 OTA 覆盖包则用覆盖包版本, 否则用内置包版本 // available: boolean,
async function currentRunningVersion() { // reason: 'no_package' | 'same_version' | 'ota_disabled' | 'unsupported' | 'ok',
try { // info: { version, sequence, notes, mandatory, bundle_size }
const v = await getOtaVersionInfo() // }
if (v?.active && v.version) return v.version // opts.devMode: true=按 build sequence 判新, false=按版本号判新 (默认)
} catch { /* ignore */ } export async function checkOtaUpdate(opts) {
return APP_VERSION const devMode = !!(opts && opts.devMode)
} if (!inTauri) {
return checkOtaUpdateWeb(devMode)
// 版本号口径判新 (Tauri + Web 共用): 服务端 version 比当前运行版本大才算新 }
async function checkByVersion() { return checkOtaUpdateTauri(devMode)
const [manifest, current] = await Promise.all([fetchOtaManifest(), currentRunningVersion()]) }
if (!manifest || !manifest.version) {
return { available: false, reason: 'no_package', info: null } // 取"当前运行版本": 已应用 OTA 覆盖包则用覆盖包版本, 否则用内置包版本
} async function currentRunningVersion() {
if (compareVersion(manifest.version, current) <= 0) { try {
return { available: false, reason: 'same_version', info: manifest } const v = await getOtaVersionInfo()
} if (v?.active && v.version) return v.version
return { } catch { /* ignore */ }
available: true, return APP_VERSION
reason: 'ok', }
info: {
version: manifest.version, // 版本号口径判新 (Tauri + Web 共用): 服务端 version 比当前运行版本大才算新
sequence: manifest.sequence, async function checkByVersion() {
notes: manifest.notes || '', const [manifest, current] = await Promise.all([fetchOtaManifest(), currentRunningVersion()])
mandatory: !!manifest.mandatory, if (!manifest || !manifest.version) {
bundle_size: manifest.bundle_size || 0, return { available: false, reason: 'no_package', info: null }
}, }
} if (compareVersion(manifest.version, current) <= 0) {
} return { available: false, reason: 'same_version', info: manifest }
}
async function checkOtaUpdateTauri(devMode) { return {
// 开发口径: sequence 判新, 直接走插件 (插件内部用当前 sequence 请求 /api/ota/check/:seq) available: true,
if (devMode) { reason: 'ok',
const { checkUpdate } = await import('tauri-plugin-hotswap-api') info: {
const r = await checkUpdate() version: manifest.version,
if (!r.available) { sequence: manifest.sequence,
return { available: false, reason: 'no_package', info: r } build: manifest.build_id || fmtBuildId(manifest.sequence),
} notes: manifest.notes || '',
return { commit: manifest.commit || '',
available: true, mandatory: !!manifest.mandatory,
reason: 'ok', bundle_size: manifest.bundle_size || 0,
info: { },
version: r.version, }
sequence: r.sequence, }
notes: r.notes,
mandatory: r.mandatory, async function checkOtaUpdateTauri(devMode) {
bundle_size: r.bundle_size, // 开发口径: sequence 判新, 直接走插件 (插件内部用当前 sequence 请求 /api/ota/check/:seq)
}, if (devMode) {
} const { checkUpdate } = await import('tauri-plugin-hotswap-api')
} const r = await checkUpdate()
// 生产口径: 版本号判新 if (!r.available) {
return checkByVersion() return { available: false, reason: 'no_package', info: r }
} }
return {
async function checkOtaUpdateWeb(devMode) { available: true,
// Web 端不能下载, 只能拿到版本号/构建号做比对, 提示用户刷新 reason: 'ok',
try { info: {
const res = await fetch(resolveUrl('/api/ota/manifest-web'), { method: 'GET' }) version: r.version,
if (!res.ok) { sequence: r.sequence,
return { available: false, reason: 'unsupported', info: null } build: fmtBuildId(r.sequence),
} notes: r.notes,
const data = await res.json() mandatory: r.mandatory,
if (!data.available) return { available: false, reason: 'no_package', info: data } bundle_size: r.bundle_size,
// Web 端拿不到本地 sequence: },
// 开发口径 (devMode) → 只要服务端版本与本地不同就算新 (近似 sequence 语义, 含回退) }
// 生产口径 → 严格比较版本号大小, 只有更高版本才算新 }
const cmp = compareVersion(data.version, APP_VERSION) // 生产口径: 版本号判新
if (devMode ? cmp === 0 : cmp <= 0) { return checkByVersion()
return { available: false, reason: 'same_version', info: data } }
}
return { async function checkOtaUpdateWeb(devMode) {
available: true, // Web 端不能下载, 只能拿到版本号/构建号做比对, 提示用户刷新
reason: 'ok', try {
info: { const res = await fetch(resolveUrl('/api/ota/manifest-web'), { method: 'GET' })
version: data.version, if (!res.ok) {
notes: data.note || '', return { available: false, reason: 'unsupported', info: null }
// web 端没有强制/mandatory 等字段, 仅展示 }
mandatory: false, const data = await res.json()
bundle_size: data.size || 0, if (!data.available) return { available: false, reason: 'no_package', info: data }
}, // Web 端拿不到本地 sequence:
} // 开发口径 (devMode) → 只要服务端版本与本地不同就算新 (近似 sequence 语义, 含回退)
} catch (e) { // 生产口径 → 严格比较版本号大小, 只有更高版本才算新
console.warn('[ota] web check failed:', e) const cmp = compareVersion(data.version, APP_VERSION)
return { available: false, reason: 'unsupported', info: null } if (devMode ? cmp === 0 : cmp <= 0) {
} return { available: false, reason: 'same_version', info: data }
} }
return {
// 下载并应用更新 (Tauri only). 调用方负责 UI 提示和错误处理 available: true,
// onProgress(received, total) 进度回调 reason: 'ok',
// 成功后必须 reload, 新资源才会生效 info: {
export async function applyOtaUpdate(onProgress) { version: data.version,
if (!inTauri) { sequence: data.sequence,
throw new Error('OTA 升级仅在 Tauri 客户端可用, Web 端请直接刷新页面') build: fmtBuildId(data.build_id || data.sequence),
} notes: data.note || '',
const mod = await import('tauri-plugin-hotswap-api') commit: data.commit || '',
// web 端没有强制/mandatory 等字段, 仅展示
// 注册一次性进度监听 (applyUpdate 期间触发) mandatory: false,
let unlisten = null bundle_size: data.size || 0,
let unlistenLifecycle = null },
if (onProgress) { }
try { } catch (e) {
unlisten = await mod.onDownloadProgress((p) => onProgress(p.downloaded, p.total)) console.warn('[ota] web check failed:', e)
} catch (e) { return { available: false, reason: 'unsupported', info: null }
console.warn('[ota] onDownloadProgress 监听失败:', e) }
} }
}
// 下载并应用更新 (Tauri only). 调用方负责 UI 提示和错误处理
// 监听 lifecycle 事件, 捕获失败详情 (download-error / apply 失败时 error 字段带具体原因) // onProgress(received, total) 进度回调
// applyUpdate() 抛出的错误往往只有泛化信息, 细节藏在 lifecycle 事件里 // 成功后必须 reload, 新资源才会生效
let lifecycleError = '' export async function applyOtaUpdate(onProgress) {
try { if (!inTauri) {
unlistenLifecycle = await mod.onLifecycle((ev) => { throw new Error('OTA 升级仅在 Tauri 客户端可用, Web 端请直接刷新页面')
if (ev.event === 'download-error' || ev.event === 'apply' || ev.event === 'check-error') { }
if (ev.error) { const mod = await import('tauri-plugin-hotswap-api')
lifecycleError = ev.error
console.error('[ota] lifecycle', ev.event, 'error:', ev.error) // 注册一次性进度监听 (applyUpdate 期间触发)
} let unlisten = null
} let unlistenLifecycle = null
}) if (onProgress) {
} catch (e) { try {
console.warn('[ota] onLifecycle 监听失败:', e) unlisten = await mod.onDownloadProgress((p) => onProgress(p.downloaded, p.total))
} } catch (e) {
console.warn('[ota] onDownloadProgress 监听失败:', e)
try { }
const newVersion = await mod.applyUpdate() }
return newVersion
} catch (e) { // 监听 lifecycle 事件, 捕获失败详情 (download-error / apply 失败时 error 字段带具体原因)
// 把 lifecycle 里的详细错误拼进抛出的异常, 方便前端展示真实原因 // applyUpdate() 抛出的错误往往只有泛化信息, 细节藏在 lifecycle 事件里
const base = e?.message || '升级失败' let lifecycleError = ''
const detail = lifecycleError ? ` (${lifecycleError})` : '' try {
const err = new Error(base + detail) unlistenLifecycle = await mod.onLifecycle((ev) => {
err.cause = e if (ev.event === 'download-error' || ev.event === 'apply' || ev.event === 'check-error') {
throw err if (ev.error) {
} finally { lifecycleError = ev.error
if (unlisten) { console.error('[ota] lifecycle', ev.event, 'error:', ev.error)
try { unlisten() } catch { /* ignore */ } }
} }
if (unlistenLifecycle) { })
try { unlistenLifecycle() } catch { /* ignore */ } } catch (e) {
} console.warn('[ota] onLifecycle 监听失败:', e)
} }
}
try {
// 回滚到内置 dist (Tauri only, 一般用于 OTA 出错时手动恢复) const newVersion = await mod.applyUpdate()
export async function rollbackOta() { return newVersion
if (!inTauri) throw new Error('仅 Tauri 客户端可用') } catch (e) {
const mod = await import('tauri-plugin-hotswap-api') // 把 lifecycle 里的详细错误拼进抛出的异常, 方便前端展示真实原因
return mod.rollback() const base = e?.message || '升级失败'
} const detail = lifecycleError ? ` (${lifecycleError})` : ''
const err = new Error(base + detail)
// 取当前 OTA 状态 (Tauri only) err.cause = e
export async function getOtaVersionInfo() { throw err
if (!inTauri) return null } finally {
try { if (unlisten) {
const mod = await import('tauri-plugin-hotswap-api') try { unlisten() } catch { /* ignore */ }
return await mod.getVersionInfo() }
} catch (e) { if (unlistenLifecycle) {
console.warn('[ota] getVersionInfo failed:', e) try { unlistenLifecycle() } catch { /* ignore */ }
return null }
} }
} }
// reload 当前页 (新资源生效) // 回滚到内置 dist (Tauri only, 一般用于 OTA 出错时手动恢复)
export function reloadApp() { export async function rollbackOta() {
window.location.reload() if (!inTauri) throw new Error('仅 Tauri 客户端可用')
} const mod = await import('tauri-plugin-hotswap-api')
return mod.rollback()
}
// 取当前 OTA 状态 (Tauri only)
export async function getOtaVersionInfo() {
if (!inTauri) return null
try {
const mod = await import('tauri-plugin-hotswap-api')
return await mod.getVersionInfo()
} catch (e) {
console.warn('[ota] getVersionInfo failed:', e)
return null
}
}
// reload 当前页 (新资源生效)
export function reloadApp() {
window.location.reload()
}
+1
View File
@@ -35,6 +35,7 @@ export const taskApi = {
create: (projectId, body) => api(`/projects/${projectId}/tasks`, { method: 'POST', body }), create: (projectId, body) => api(`/projects/${projectId}/tasks`, { method: 'POST', body }),
detail: (id) => api(`/tasks/${id}`), detail: (id) => api(`/tasks/${id}`),
update: (id, body) => api(`/tasks/${id}`, { method: 'PUT', body }), update: (id, body) => api(`/tasks/${id}`, { method: 'PUT', body }),
reorder: (projectId, ids) => api(`/projects/${projectId}/tasks/reorder`, { method: 'POST', body: { ids } }),
remove: (id) => api(`/tasks/${id}`, { method: 'DELETE' }), remove: (id) => api(`/tasks/${id}`, { method: 'DELETE' }),
copy: (id, projectId) => api(`/tasks/${id}/copy`, { method: 'POST', body: { projectId } }), copy: (id, projectId) => api(`/tasks/${id}/copy`, { method: 'POST', body: { projectId } }),
} }
+163
View File
@@ -0,0 +1,163 @@
import { useCallback, useEffect, useRef, useState } from 'react'
// 打卡项组内拖动排序 (MergedView 分组列表)
// 触发路径:
// 1. 把手 (bindHandle, 推荐): 卡片左侧 ⠿ 把手 touch-action:none +
// 非 passive touchstart preventDefault —— 浏览器按规范完全不接管该
// 触摸, 指针流不会被滚动抢占取消, Android WebView 上可靠;
// 2. 长按 (bind): 长按 400ms 激活。pan-y 下列表可滚时手指纵向抖动会
// 触发滚动接管 (pointercancel), 仅作补充入口。
// 拖动: 激活时克隆原卡片作为跟手浮层 (淡化), 原位保留虚线占位;
// elementFromPoint 命中同组 [data-habit-row] 作为落点 (data-habit-period
// 必须一致, 即只在 每天/每周/每月 组内排序), 按指针在目标卡上半/下半
// 决定插到其前/后, 松手提交。
export default function useHabitDrag({ onDrop }) {
const [dragItem, setDragItem] = useState(null) // 拖动中的打卡项 (视觉态)
const [over, setOver] = useState(null) // { id, after } 落点
const st = useRef({ timer: null, active: false, startX: 0, startY: 0, item: null, over: null, srcEl: null, ghost: null })
const lastEnd = useRef(0)
// onDrop 取"最新一次渲染"的闭包: bind/bindHandle 是空依赖 useCallback,
// 若直接捕获 onDrop, 会固化首次渲染(数据未加载, groups=[])的落位函数,
// 松手时静默早退。用 ref 桥接到实时实现。
const onDropRef = useRef(onDrop)
onDropRef.current = onDrop
// 跟手浮层: 激活时克隆原卡片挂到 body (脱离页签滑动容器的 transform),
// 移动时直接改 transform, 不经 React 渲染
const moveGhost = (x, y) => {
const g = st.current.ghost
if (g) g.style.transform = `translate(${x}px, ${y}px) translate(-50%, -60%)`
}
const mountGhost = (x, y) => {
const src = st.current.srcEl
if (!src) return
const clone = src.cloneNode(true)
clone.classList.remove('dragging', 'dragover', 'after', 'before')
clone.style.width = `${src.offsetWidth}px`
const g = document.createElement('div')
g.className = 'drag-ghost'
g.appendChild(clone)
document.body.appendChild(g)
st.current.ghost = g
moveGhost(x, y)
}
const cleanup = useCallback(() => {
if (st.current.timer) { clearTimeout(st.current.timer); st.current.timer = null }
st.current.active = false
st.current.over = null
st.current.srcEl = null
if (st.current.ghost) { st.current.ghost.remove(); st.current.ghost = null }
setDragItem(null)
setOver(null)
}, [])
useEffect(() => () => cleanup(), [cleanup])
/** 拖动刚结束 (350ms 内): 抑制松手合成的 click 误触打卡 */
const justEnded = useCallback(() => Date.now() - lastEnd.current < 350, [])
const hitRow = (x, y) => {
const el = document.elementFromPoint(x, y)
const rowEl = el?.closest?.('[data-habit-row]')
if (!rowEl) return null
const r = rowEl.getBoundingClientRect()
const id = Number(rowEl.dataset.habitId)
if (id === st.current.item.id) return null
// 允许跨组: 落到其他周期组的目标卡上 = 同时改周期 + 落位 (由 onDrop 处理)
const period = rowEl.dataset.habitPeriod
return { id, period, after: y > r.top + r.height / 2 }
}
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()
moveGhost(ev.clientX, ev.clientY)
const hit = hitRow(ev.clientX, ev.clientY)
if (hit) { s.over = hit; setOver(hit) }
else if (s.over) { s.over = null; setOver(null) }
}
const onUp = () => {
const s = st.current
if (s.active && s.item) {
lastEnd.current = Date.now()
if (s.over && s.over.id !== s.item.id) onDropRef.current?.(s.item, s.over.id, s.over.after, s.over.period)
}
cleanup()
}
// 指针监听装配: instant=true 时按下即激活 (把手路径), 否则长按 400ms 激活
const armListeners = (ev, instant, item) => {
try { ev.currentTarget.setPointerCapture(ev.pointerId) } catch { /* ignore */ }
const el = ev.currentTarget
const move = (e2) => onMove(e2)
const tm = (e2) => { if (st.current.active) e2.preventDefault() }
const ts = (e2) => e2.preventDefault()
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.removeEventListener('touchmove', tm)
el.removeEventListener('touchstart', ts)
}
el.addEventListener('pointermove', move)
el.addEventListener('pointerup', up)
el.addEventListener('pointercancel', cancel)
el.addEventListener('touchmove', tm, { passive: false })
if (instant) el.addEventListener('touchstart', ts, { passive: false })
const activate = () => {
st.current.active = true
setDragItem(item)
mountGhost(st.current.startX, st.current.startY)
if (navigator.vibrate) navigator.vibrate(15)
}
if (instant) activate()
else st.current.timer = setTimeout(activate, 400)
}
/** 绑定到卡片: 卡片需 touch-action: pan-y (长按路径) */
const bind = useCallback((item) => ({
onPointerDown: (ev) => {
if (ev.button !== undefined && ev.button !== 0) return
st.current = {
...st.current, startX: ev.clientX, startY: ev.clientY, item, active: false, over: null,
srcEl: ev.currentTarget.closest('[data-habit-row]'),
}
armListeners(ev, false, item)
},
onContextMenu: (ev) => ev.preventDefault(),
// eslint-disable-next-line react-hooks/exhaustive-deps
}), [])
/** 绑定到拖动把手: 把手需 touch-action: none, 按下即拖 */
const bindHandle = useCallback((item) => ({
onPointerDown: (ev) => {
if (ev.button !== undefined && ev.button !== 0) return
ev.stopPropagation() // 不触发卡片自身的长按路径
st.current = {
...st.current, startX: ev.clientX, startY: ev.clientY, item, active: false, over: null,
srcEl: ev.currentTarget.closest('[data-habit-row]'),
}
armListeners(ev, true, item)
},
onContextMenu: (ev) => ev.preventDefault(),
// eslint-disable-next-line react-hooks/exhaustive-deps
}), [])
return { dragItem, over, bind, bindHandle, cancel: cleanup, justEnded }
}
+22
View File
@@ -0,0 +1,22 @@
// 负责人头像: 姓氏首字 + 按名字稳定的底色; 未指派灰色圆点
const COLORS = ['#1677ff', '#00b578', '#ff8f1f', '#a855f7', '#14b8a6', '#f43f9e', '#5a6a80']
export default function Avatar({ name, size = 18 }) {
const seed = [...(name || '')].reduce((a, ch) => a + ch.charCodeAt(0), 0)
const color = COLORS[seed % COLORS.length]
return (
<span
className="avatar"
title={name || '未指派'}
style={{
width: size,
height: size,
fontSize: Math.round(size * 0.52),
background: name ? color : '#eef1f5',
color: name ? '#fff' : '#b6bcc4',
}}
>
{name ? name.slice(0, 1) : '·'}
</span>
)
}
+4 -1
View File
@@ -38,7 +38,10 @@ export default function CopyTaskPopup({ task, visible, onClose, onDone }) {
<Popup <Popup
visible={visible} visible={visible}
onMaskClick={onClose} onMaskClick={onClose}
className="kb-safe" bodyStyle={{
borderTopLeftRadius: 12, borderTopRightRadius: 12,
padding: '16px 16px calc(env(safe-area-inset-bottom, 0px) + 20px)',
}}
> >
<div className="sheet-title">跨项目拷贝</div> <div className="sheet-title">跨项目拷贝</div>
<div className="sheet-sub">「{task.title}」→ 选择目标项目;源任务保持不变</div> <div className="sheet-sub">「{task.title}」→ 选择目标项目;源任务保持不变</div>
+8 -4
View File
@@ -1,12 +1,11 @@
import { useEffect, useMemo, useRef, useState } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
import { getRecentIds, getRecentOrder, setRecentOrder } from '../../pages/task/prefs.js' import { getRecentIds, getRecentOrder, setRecentOrder } from '../../pages/task/prefs.js'
import { colorOfProject } from '../../pages/task/projectIcon.js'
// 首屏最近项目条 (T-VW-08/10) // 首屏最近项目条 (T-VW-08/10)
// - 展示最近使用的 3 个项目 + 「更多项目」入口 // - 展示最近使用的 3 个项目 + 「更多项目」入口
// - 顺序: 手动排序(recentOrder)优先, 其次最近访问(recentIds), 兜底列表顺序 // - 顺序: 手动排序(recentOrder)优先, 其次最近访问(recentIds), 兜底列表顺序
// - 长按卡片 300ms 进入拖拽态, 水平拖动实时换位, 松手持久化(设备端) // - 长按卡片 300ms 进入拖拽态, 水平拖动实时换位, 松手持久化(设备端)
const COLORS = ['#1677ff', '#00b578', '#ff8f1f', '#a855f7', '#14b8a6', '#f43f9e']
const colorOf = (id) => COLORS[Math.abs(id) % COLORS.length]
const LONG_PRESS = 300 const LONG_PRESS = 300
const MOVE_TOLERANCE = 8 const MOVE_TOLERANCE = 8
@@ -123,8 +122,13 @@ export default function RecentProjects({ projects, currentId, onSelect, onMore }
onMouseDown={(ev) => beginPress(i, ev)} onMouseDown={(ev) => beginPress(i, ev)}
onClick={() => { if (!active.current && dragIdx < 0) onSelect?.(p.id) }} onClick={() => { if (!active.current && dragIdx < 0) onSelect?.(p.id) }}
> >
<div className="rp-ico" style={{ background: colorOf(p.id) }}> <div
{(p.name || '?').slice(0, 1)} className="rp-ico"
style={p.icon
? { background: `url(${p.icon}) center/cover no-repeat` }
: { background: colorOfProject(p) }}
>
{p.icon ? '' : (p.name || '?').slice(0, 1)}
</div> </div>
<div className="rp-name">{p.name}</div> <div className="rp-name">{p.name}</div>
<div className="rp-meta">{p.taskCount ?? 0} 个任务</div> <div className="rp-meta">{p.taskCount ?? 0} 个任务</div>
+39 -22
View File
@@ -1,4 +1,6 @@
import StatusDot from './StatusDot.jsx' import StatusDot from './StatusDot.jsx'
import Avatar from './Avatar.jsx'
import { kindColor } from '../../pages/task/kind.js'
// 看板卡片 (T-UI-02): 轻量化 // 看板卡片 (T-UI-02): 轻量化
// - 标题 + 状态点 (T-ST-09) // - 标题 + 状态点 (T-ST-09)
@@ -20,44 +22,59 @@ export default function TaskCard({
onClick={onClick} onClick={onClick}
{...(bindDrag ? bindDrag(task) : {})} {...(bindDrag ? bindDrag(task) : {})}
> >
<div className="brow1"> {/* 左侧竖排: 状态点 + ⋯ */}
<span className="btitle">{task.title}</span> <div className="bcard-left">
<span <span
className="bstat" className="bstat"
onClick={(e) => { e.stopPropagation(); onToggle?.(task) }} onClick={(e) => { e.stopPropagation(); onToggle?.(task) }}
onPointerDown={(e) => e.stopPropagation()}
title="点按切换完成" title="点按切换完成"
> >
<StatusDot status={task.status} /> <StatusDot status={task.status} />
</span> </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 && ( {onMore && (
<span <span
className="bmore-btn" className="bmore-btn"
onClick={(e) => { e.stopPropagation(); onMore(task) }} onClick={(e) => { e.stopPropagation(); onMore(task) }}
onPointerDown={(e) => e.stopPropagation()}
>⋯</span> >⋯</span>
)} )}
</div> </div>
{shown.map((c) => ( <div className="bcard-body">
<div <div className="brow1">
key={c.id} <span className="btitle">{task.title}</span>
className={`bsub${c.status === 'done' ? ' done' : ''}`}
onClick={(e) => { e.stopPropagation(); onToggleSub?.(c) }}
>
<span className="sbox" />
<span className="stitle">{c.title}</span>
<StatusDot status={c.status} />
</div> </div>
))}
{rest > 0 && <div className="bmore">+{rest} 个子任务</div>} <div className="bmeta">
{task.markDate && (
<span className="date-tag">{task.markDate.slice(5, 10).replace('-', '/')}</span>
)}
{task.kind && (
<span className="kind-tag" style={{ background: kindColor(task.kind) }}>{task.kind}</span>
)}
<span style={{ display: 'flex', alignItems: 'center', gap: 6, marginLeft: 'auto', minWidth: 0, justifyContent: 'flex-end' }}>
<Avatar name={task.assignee?.name} size={18} />
{task.members?.length > 0 && (
<span className="bmembers">+ {task.members.map((m) => m.name).join('、')}</span>
)}
</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>
</div> </div>
) )
} }
+39 -42
View File
@@ -1,49 +1,37 @@
import { useEffect, useRef } from 'react'
import StatusDot from './StatusDot.jsx' import StatusDot from './StatusDot.jsx'
import Avatar from './Avatar.jsx'
import { kindColor } from '../../pages/task/kind.js'
const statusLabel = (s) => (s === 'done' ? '完成' : s === 'doing' ? '执行中' : '未开始')
// 列表视图的一行 (Todoist 风格, T-UI-01) // 列表视图的一行 (Todoist 风格, T-UI-01)
// 长按 500ms → onLongPress (移动到列菜单); 点击其他区域 → onClick (编辑) // 点击行体 → 打开详情; 长按 400ms 后左右拖动换列 (bindDrag 来自 useRowDrag)
const LONG_PRESS = 500 // 子任务与普通任务同构: 标题一行 + 状态元信息一行 (字体略小), 不显示分组标签
// 复选框 / ⋯ 是独立控件: pointerdown 不冒泡, 不发起拖动,
// 避免长按行后抬手时合成的 click 落在控件上误触状态切换
export default function TaskRow({ export default function TaskRow({
task, onToggle, onClick, onMore, onLongPress, indent = false, children, task, onToggle, onClick, onMore, bindDrag, bindDragHandle, dragging = false, dragOver, indent = false, children,
}) { }) {
const timer = useRef(null)
const moved = useRef(false)
const clear = () => { if (timer.current) { clearTimeout(timer.current); timer.current = null } }
useEffect(() => () => clear(), [])
const startPress = () => {
moved.current = false
clear()
if (!onLongPress) return
timer.current = setTimeout(() => {
if (!moved.current) {
if (navigator.vibrate) navigator.vibrate(15)
onLongPress(task)
}
}, LONG_PRESS)
}
const cancelPress = () => clear()
const markMoved = () => { moved.current = true; clear() }
const done = task.status === 'done' const done = task.status === 'done'
return ( return (
<> <>
<div <div
className={`${indent ? 'sub-row' : 'task-row'}${done ? ' done' : ''}`} className={
`${indent ? 'sub-row' : 'task-row'}${done ? ' done' : ''}`
+ (dragging ? ' dragging' : '')
+ (dragOver ? ` dragover ${dragOver.after ? 'after' : 'before'}` : '')
}
data-task-id={indent ? undefined : task.id} data-task-id={indent ? undefined : task.id}
onClick={() => { if (!moved.current) onClick?.(task) }} onClick={onClick}
onTouchStart={startPress} {...(!indent && bindDrag ? bindDrag(task) : {})}
onTouchMove={markMoved}
onTouchEnd={cancelPress}
onMouseDown={startPress}
onMouseUp={cancelPress}
> >
{!indent && bindDragHandle && (
<span className="grip" title="拖动排序 / 换列" {...bindDragHandle(task)}>⠿</span>
)}
<div <div
className="box" className="box"
onClick={(e) => { e.stopPropagation(); onToggle?.(task) }} onClick={(e) => { e.stopPropagation(); onToggle?.(task) }}
onPointerDown={(e) => e.stopPropagation()}
> >
<svg viewBox="0 0 14 14" width={indent ? 10 : 11} height={indent ? 10 : 11}> <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" /> <path d="M2.5 7.2l3 3 6-6.4" stroke="#fff" strokeWidth="2" fill="none" strokeLinecap="round" />
@@ -51,28 +39,37 @@ export default function TaskRow({
</div> </div>
{indent ? ( {indent ? (
<> <div className="sub-main">
<span className="sub-title">{task.title}</span> <span className="sub-title">{task.title}</span>
<StatusDot status={task.status} pill /> <div className="task-meta sub">
</> <StatusDot status={task.status} />
<span>{statusLabel(task.status)}</span>
</div>
</div>
) : ( ) : (
<> <>
<div className="task-main"> <div className="task-main">
<div className="task-title">{task.title}</div> <div className="task-title">{task.title}</div>
{/* 无类型/成员也保留该行, 与有标签的行等高 */}
<div className="task-meta"> <div className="task-meta">
<StatusDot status={task.status} /> {task.markDate && (
<span>{task.status === 'done' ? '完成' : task.status === 'doing' ? '执行中' : '未开始'}</span> <span className="date-tag">{task.markDate.slice(5, 10).replace('-', '/')}</span>
{task.column?.name && <span className="col">{task.column.name}</span>} )}
<span className={`assignee${task.assignee ? '' : ' none'}`}> {task.kind && (
{task.assignee?.name || '未指派'} <span className="kind-tag" style={{ background: kindColor(task.kind) }}>{task.kind}</span>
</span> )}
{task.members?.length > 0 && ( {task.members?.length > 0 && (
<span className="members">+ {task.members.map((m) => m.name).join('、')}</span> <span className="members">+ {task.members.map((m) => m.name).join('、')}</span>
)} )}
</div> </div>
</div> </div>
<div className="task-right"> <div className="task-right">
<span className="more" onClick={(e) => { e.stopPropagation(); onMore?.(task) }}>⋯</span> <span
className="more"
onClick={(e) => { e.stopPropagation(); onMore?.(task) }}
onPointerDown={(e) => e.stopPropagation()}
>⋯</span>
<Avatar name={task.assignee?.name} size={20} />
</div> </div>
</> </>
)} )}
+1 -1
View File
@@ -34,7 +34,7 @@ export default function UserPicker({
} }
return ( return (
<Popup visible={visible} onMaskClick={onClose} position="bottom" className="kb-safe"> <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-title">{title}</div>
<div className="sheet-sub">仅可选择当前项目的成员</div> <div className="sheet-sub">仅可选择当前项目的成员</div>
+102 -36
View File
@@ -1,18 +1,44 @@
import { useCallback, useEffect, useRef, useState } from 'react' import { useCallback, useEffect, useRef, useState } from 'react'
// 看板卡片列拖动 (重点设计 §7.1, 2026-10-24 重做) // 看板卡片列拖动 (重点设计 §7.1)
// 方案要点 (替代旧版"手指跟随浮层 + 落点即子任务"的不可靠实现): // 方案要点:
// 1. Pointer Events + setPointerCapture —— 触摸/鼠标统一, 捕获后不丢事件 // 1. Pointer Events + setPointerCapture —— 触摸/鼠标统一, 捕获后不丢事件
// 2. 长按 400ms 激活 (先给滚动让路: 激活前移动 >10px 视为滚动并取消) // 2. 长按 400ms 激活 (激活前移动 >10px 视为滚动, 取消本次拖动)
// 3. 激活后卡片原位抬起 (不跟随手指, 无浮层/无任务名), touch-action 由捕获保证 // 3. 激活后卡片原位抬起 (不跟随手指, 无浮层/无任务名)
// 4. 拖动中持续 elementFromPoint 命中 [data-column-id] 高亮目标列; // 4. 拖动中持续 elementFromPoint 命中 [data-column-id] 高亮目标列;
// 指针靠近容器左右边缘时自动横向滚动, 可拖到屏幕外的列 // 指针靠近容器左右边缘时 rAF 自动横滚, 可拖到屏幕外的列
// 5. 松手/取消(pointercancel 也要处理) → 落到目标列(≠原列)才提交; // 5. 松手落到目标列 (≠原列) 才提交; pointercancel (浏览器接管手势,
// 拖到卡片上不再转子任务 —— 统一走详情页「转为子任务」 // 如触发纵向滚动) 只放弃不提交
// 6. 防误触: 卡片禁长按系统菜单(contextmenu); 拖动结束后 350ms 内的
// click 视为拖动余波, 不打开详情 (justEnded)
export default function useColumnDrag({ onDrop, scrollSelector = '.board-wrap' } = {}) { export default function useColumnDrag({ onDrop, scrollSelector = '.board-wrap' } = {}) {
const [dragTask, setDragTask] = useState(null) // 拖动中的任务 (视觉态) const [dragTask, setDragTask] = useState(null) // 拖动中的任务 (视觉态)
const [overCol, setOverCol] = useState(null) // 高亮的目标列 id const [overCol, setOverCol] = useState(null) // 高亮的目标列 id
const st = useRef({ timer: null, active: false, startX: 0, startY: 0, task: null, autoScroll: 0, raf: 0 }) const st = useRef({ timer: null, active: false, startX: 0, startY: 0, task: null, autoScroll: 0, raf: 0, srcEl: null, ghost: null })
const lastEnd = useRef(0) // 拖动结束时刻
// onDrop 经 ref 桥接实时闭包 (bind 空依赖 useCallback, 防陈旧)
const onDropRef = useRef(onDrop)
onDropRef.current = onDrop
// 跟手浮层: 激活时克隆原卡片挂到 body, 移动时直接改 transform
const moveGhost = (x, y) => {
const g = st.current.ghost
if (g) g.style.transform = `translate(${x}px, ${y}px) translate(-50%, -60%)`
}
const mountGhost = (x, y) => {
const src = st.current.srcEl
if (!src) return
const clone = src.cloneNode(true)
clone.classList.remove('dragging', 'hl')
clone.style.width = `${src.offsetWidth}px`
const g = document.createElement('div')
g.className = 'drag-ghost'
g.appendChild(clone)
document.body.appendChild(g)
st.current.ghost = g
moveGhost(x, y)
}
const stopAutoScroll = () => { const stopAutoScroll = () => {
st.current.autoScroll = 0 st.current.autoScroll = 0
@@ -22,6 +48,8 @@ export default function useColumnDrag({ onDrop, scrollSelector = '.board-wrap' }
const cleanup = useCallback(() => { const cleanup = useCallback(() => {
if (st.current.timer) { clearTimeout(st.current.timer); st.current.timer = null } if (st.current.timer) { clearTimeout(st.current.timer); st.current.timer = null }
st.current.active = false st.current.active = false
st.current.srcEl = null
if (st.current.ghost) { st.current.ghost.remove(); st.current.ghost = null }
stopAutoScroll() stopAutoScroll()
setDragTask(null) setDragTask(null)
setOverCol(null) setOverCol(null)
@@ -29,6 +57,9 @@ export default function useColumnDrag({ onDrop, scrollSelector = '.board-wrap' }
useEffect(() => () => cleanup(), [cleanup]) useEffect(() => () => cleanup(), [cleanup])
/** 拖动刚结束 (350ms 内): 抑制松手合成的 click 误开详情页 */
const justEnded = useCallback(() => Date.now() - lastEnd.current < 350, [])
const hitColumn = (x, y) => { const hitColumn = (x, y) => {
const el = document.elementFromPoint(x, y) const el = document.elementFromPoint(x, y)
const colEl = el?.closest?.('[data-column-id]') const colEl = el?.closest?.('[data-column-id]')
@@ -54,17 +85,6 @@ export default function useColumnDrag({ onDrop, scrollSelector = '.board-wrap' }
st.current.raf = requestAnimationFrame(step) st.current.raf = requestAnimationFrame(step)
} }
const onPointerDown = (task) => (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)
}
const onPointerMove = (ev) => { const onPointerMove = (ev) => {
const s = st.current const s = st.current
if (!s.active) { if (!s.active) {
@@ -74,6 +94,7 @@ export default function useColumnDrag({ onDrop, scrollSelector = '.board-wrap' }
return return
} }
ev.preventDefault() ev.preventDefault()
moveGhost(ev.clientX, ev.clientY)
const col = hitColumn(ev.clientX, ev.clientY) const col = hitColumn(ev.clientX, ev.clientY)
setOverCol(col) setOverCol(col)
autoScroll(document.querySelector(scrollSelector), ev.clientX) autoScroll(document.querySelector(scrollSelector), ev.clientX)
@@ -82,33 +103,78 @@ export default function useColumnDrag({ onDrop, scrollSelector = '.board-wrap' }
const onPointerUp = (ev) => { const onPointerUp = (ev) => {
const s = st.current const s = st.current
if (s.active) { if (s.active) {
lastEnd.current = Date.now()
const col = hitColumn(ev.clientX, ev.clientY) const col = hitColumn(ev.clientX, ev.clientY)
if (col && s.task && col !== s.task.columnId) onDrop?.(s.task, col) if (col && s.task && col !== s.task.columnId) onDropRef.current?.(s.task, col)
} }
cleanup() cleanup()
} }
// 指针监听装配 (capture + 非 passive touchmove; instant=true 把手路径
// 再挂 touchstart preventDefault, 浏览器完全不接管该触摸)
const armListeners = (ev, instant) => {
try { ev.currentTarget.setPointerCapture(ev.pointerId) } catch { /* ignore */ }
const el = ev.currentTarget
const move = (e2) => onPointerMove(e2)
const tm = (e2) => { if (st.current.active) e2.preventDefault() }
const ts = (e2) => e2.preventDefault()
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.removeEventListener('touchmove', tm)
el.removeEventListener('touchstart', ts)
}
el.addEventListener('pointermove', move)
el.addEventListener('pointerup', up)
el.addEventListener('pointercancel', cancel)
el.addEventListener('touchmove', tm, { passive: false })
if (instant) el.addEventListener('touchstart', ts, { passive: false })
}
const startDrag = (ev, task, instant) => {
st.current = {
...st.current, startX: ev.clientX, startY: ev.clientY, task, active: false,
srcEl: ev.currentTarget.closest('.bcard'),
}
const activate = () => {
st.current.active = true
setDragTask(task)
if (navigator.vibrate) navigator.vibrate(15)
mountGhost(st.current.startX, st.current.startY)
}
if (instant) activate()
else st.current.timer = setTimeout(activate, 400)
armListeners(ev, instant)
}
/** 绑定到卡片: 需要卡片元素设置 touch-action: pan-y */ /** 绑定到卡片: 需要卡片元素设置 touch-action: pan-y */
const bind = useCallback((task) => ({ const bind = useCallback((task) => ({
onPointerDown: (ev) => { onPointerDown: (ev) => {
// 捕获前先让事件正常冒泡(点击等); 只做长按计时 if (ev.button !== undefined && ev.button !== 0) return
onPointerDown(task)(ev) startDrag(ev, task, false)
// 捕获: 后续 move/up 都派发给该元素, 不丢
try { ev.currentTarget.setPointerCapture(ev.pointerId) } catch { /* ignore */ }
const el = ev.currentTarget
const move = (e2) => onPointerMove(e2)
const up = (e2) => {
onPointerUp(e2)
el.removeEventListener('pointermove', move)
el.removeEventListener('pointerup', up)
el.removeEventListener('pointercancel', up)
}
el.addEventListener('pointermove', move)
el.addEventListener('pointerup', up)
el.addEventListener('pointercancel', up)
}, },
// 长按不弹系统菜单 (配合 CSS user-select:none, 防长按变文字选择)
onContextMenu: (ev) => ev.preventDefault(),
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}), []) }), [])
return { dragTask, overCol, bind, cancel: cleanup } /** 绑定到卡片左侧拖动把手: 把手需 touch-action: none, 按下立即拖动 */
const bindHandle = useCallback((task) => ({
onPointerDown: (ev) => {
if (ev.button !== undefined && ev.button !== 0) return
ev.stopPropagation() // 不触发卡片自身的长按路径
startDrag(ev, task, true)
},
onContextMenu: (ev) => ev.preventDefault(),
// eslint-disable-next-line react-hooks/exhaustive-deps
}), [])
return { dragTask, overCol, bind, bindHandle, cancel: cleanup, justEnded }
} }
+195
View File
@@ -0,0 +1,195 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
// 列表视图任务行拖动: 组间换列 + 组内排序
// 触发: 行长按 400ms, 或按住行首 ⠿ 把手 (touch-action:none + touchstart
// preventDefault, 浏览器不接管该触摸, 是可靠入口)
// 拖动: 克隆原行作为跟手浮层 (淡化), 原位保留占位;
// 手指划到其他分组 ([data-column-id]) → 换列; 划到本组某行
// ([data-task-id]) 的上/下半 → 组内插入到其前/后; 松手提交。
// touch-action: pan-y + 激活后非 passive touchmove preventDefault 尽力
// 拦截滚动接管; pointercancel 只放弃不提交。
// 注: 列表只展示有任务的列, 空列拖不进去 (用看板视图可拖空列)。
export default function useRowDrag({ columns, onDrop, onReorder }) {
// { task, fromIdx, toIdx }: 拖动中的行 + 当前目标列下标
const [drag, setDrag] = useState(null)
// { id, after }: 组内落点 (插入到某行前/后)
const [over, setOver] = useState(null)
const st = useRef({ timer: null, active: false, startX: 0, startY: 0, task: null, fromIdx: 0, toIdx: 0, over: null, srcEl: null, ghost: null })
const lastEnd = useRef(0) // 拖动结束时刻
// onDrop/onReorder 经 ref 桥接实时闭包 (bind 是 useCallback, 防陈旧)
const onDropRef = useRef(onDrop)
onDropRef.current = onDrop
const onReorderRef = useRef(onReorder)
onReorderRef.current = onReorder
// 目标列顺序 = 列表分组顺序 (sortOrder)
const ordered = useMemo(
() => [...(columns || [])].sort((a, b) => a.sortOrder - b.sortOrder),
[columns],
)
const orderedRef = useRef(ordered)
orderedRef.current = ordered
// 跟手浮层: 激活时克隆原行挂到 body, 移动时直接改 transform
const moveGhost = (x, y) => {
const g = st.current.ghost
if (g) g.style.transform = `translate(${x}px, ${y}px) translate(-50%, -60%)`
}
const mountGhost = (x, y) => {
const src = st.current.srcEl
if (!src) return
const clone = src.cloneNode(true)
clone.classList.remove('dragging', 'dragover', 'after', 'before')
clone.style.width = `${src.offsetWidth}px`
const g = document.createElement('div')
g.className = 'drag-ghost'
g.appendChild(clone)
document.body.appendChild(g)
st.current.ghost = g
moveGhost(x, y)
}
const cleanup = useCallback(() => {
if (st.current.timer) { clearTimeout(st.current.timer); st.current.timer = null }
st.current.active = false
st.current.over = null
st.current.srcEl = null
if (st.current.ghost) { st.current.ghost.remove(); st.current.ghost = null }
setDrag(null)
setOver(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()
moveGhost(ev.clientX, ev.clientY)
// 组内落点: 命中本组 ([data-column-id]=当前列) 的某条顶层行
const hitEl = document.elementFromPoint(ev.clientX, ev.clientY)
const rowEl = hitEl?.closest?.('[data-task-id]')
const colEl = hitEl?.closest?.('[data-column-id]')
const colId = colEl ? Number(colEl.dataset.columnId) : null
const curCol = orderedRef.current[s.toIdx]?.id
if (rowEl && colId === s.task.columnId) {
const id = Number(rowEl.dataset.taskId)
if (id !== s.task.id) {
const r = rowEl.getBoundingClientRect()
const hit = { id, after: ev.clientY > r.top + r.height / 2 }
if (!s.over || s.over.id !== hit.id || s.over.after !== hit.after) {
s.over = hit
setOver(hit)
}
return
}
}
if (s.over) { s.over = null; setOver(null) }
// 换列: 手指划到哪个分组就是目标列, 划出所有分组时保持上一目标
if (colId != null) {
const to = orderedRef.current.findIndex((c) => c.id === colId)
if (to >= 0 && 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()
if (s.over) {
onReorderRef.current?.(s.task, s.over.id, s.over.after)
} else {
const target = orderedRef.current[s.toIdx]
if (target && target.id !== s.task.columnId) onDropRef.current?.(s.task, target.id)
}
}
cleanup()
}
// 指针监听装配 (capture + 非 passive touchmove: 激活后尽力拦截滚动接管;
// instant=true 把手路径再挂 touchstart preventDefault, 规范上禁止浏览器对
// 整个触摸的默认处理, 彻底杜绝滚动抢占)
const armListeners = (ev, instant = false) => {
try { ev.currentTarget.setPointerCapture(ev.pointerId) } catch { /* ignore */ }
const el = ev.currentTarget
const move = (e2) => onMove(e2)
const tm = (e2) => { if (st.current.active) e2.preventDefault() }
const ts = (e2) => e2.preventDefault()
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.removeEventListener('touchmove', tm)
el.removeEventListener('touchstart', ts)
}
el.addEventListener('pointermove', move)
el.addEventListener('pointerup', up)
el.addEventListener('pointercancel', cancel)
el.addEventListener('touchmove', tm, { passive: false })
if (instant) el.addEventListener('touchstart', ts, { passive: false })
}
const startDrag = (ev, task, instant) => {
const fromIdx = orderedRef.current.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, over: null, srcEl: ev.currentTarget.closest('.task-row'),
}
const activate = () => {
st.current.active = true
if (navigator.vibrate) navigator.vibrate(15)
setDrag({ task, fromIdx, toIdx: fromIdx })
mountGhost(st.current.startX, st.current.startY)
}
if (instant) activate()
else st.current.timer = setTimeout(activate, 400)
armListeners(ev, instant)
}
/** 绑定到列表行: 行元素需设置 touch-action: pan-y (长按路径, 可滚场景不可靠) */
const bind = useCallback((task) => ({
onPointerDown: (ev) => {
if (ev.button !== undefined && ev.button !== 0) return
startDrag(ev, task, false)
},
// 长按不弹系统菜单 (配合 CSS user-select:none, 防长按变文字选择)
onContextMenu: (ev) => ev.preventDefault(),
// eslint-disable-next-line react-hooks/exhaustive-deps
}), [])
/** 绑定到行首拖动把手: 把手需 touch-action: none, 按下立即拖动 */
const bindHandle = useCallback((task) => ({
onPointerDown: (ev) => {
if (ev.button !== undefined && ev.button !== 0) return
ev.stopPropagation() // 不触发行自身的长按路径
startDrag(ev, task, true)
},
onContextMenu: (ev) => ev.preventDefault(),
// eslint-disable-next-line react-hooks/exhaustive-deps
}), [])
return { drag, over, bind, bindHandle, 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
}
-35
View File
@@ -1,35 +0,0 @@
import { useEffect } from 'react'
// 键盘遮挡统一方案 (重点设计 §7.2):
// 全局唯一监听器, 把键盘插入量写入 CSS 变量 --kb-inset (0 或 px 字符串),
// 任何底部弹层只要挂 .kb-safe 类即可被抬到键盘上方, 组件不再各自接键盘逻辑。
// 信号源 (双保险):
// 1) visualViewport 的 resize/scroll —— Android WebView 键盘弹出时它先变小
// 2) window resize —— 配合 index.html 的 interactive-widget=resizes-content,
// Chromium 系 WebView 会整体收缩布局视口
// 基线: 记录见过的最大视口高 (键盘收起时), 避免"弹出键盘后基线被拉低"的漂移。
export default function useKeyboardInsetWatcher() {
useEffect(() => {
const root = document.documentElement
const vv = window.visualViewport
let maxH = window.innerHeight
const calc = () => {
const h = vv ? vv.height : window.innerHeight
if (h > maxH) maxH = h
const inset = Math.max(0, Math.round(maxH - h - (vv ? vv.offsetTop : 0)))
root.style.setProperty('--kb-inset', inset > 90 ? inset + 'px' : '0px')
}
calc()
window.addEventListener('resize', calc)
vv?.addEventListener('resize', calc)
vv?.addEventListener('scroll', calc)
return () => {
window.removeEventListener('resize', calc)
vv?.removeEventListener('resize', calc)
vv?.removeEventListener('scroll', calc)
root.style.setProperty('--kb-inset', '0px')
}
}, [])
}
+262 -247
View File
@@ -1,247 +1,262 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { Avatar, Button, List, ProgressBar, Switch, Toast } from 'antd-mobile' import { Avatar, Button, List, ProgressBar, Switch, Toast } from 'antd-mobile'
import { useAuth } from '../auth/AuthContext.jsx' import { useAuth } from '../auth/AuthContext.jsx'
import { APP_VERSION, BUILD_ID } from '../version.js' import {
import { applyOtaUpdate,
applyOtaUpdate, checkOtaUpdate,
checkOtaUpdate, fmtBuildId,
getCurrentVersionInfo, getCurrentVersionInfo,
getOtaDevMode, getOtaDevMode,
getOtaVersionInfo, getOtaVersionInfo,
reloadApp, reloadApp,
rollbackOta, rollbackOta,
setOtaDevMode, setOtaDevMode,
} from '../api/ota.js' } from '../api/ota.js'
// "我" 页: 顶部 NavBar + 用户头像卡片 + 信息列表 + OTA 升级区 const inTauri = typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window
// OTA 区:
// - 显示当前 APP_VERSION (前端包, 对应内置 dist) // "我" 页: 顶部 NavBar + 用户头像卡片 + 信息列表 + OTA 升级区
// - 显示当前 hotswap 状态 (是否已应用覆盖包) // OTA 区:
// - [检查更新]: 走 checkOtaUpdate() // - 显示当前 APP_VERSION (前端包, 对应内置 dist)
// - [立即升级]: 走 applyOtaUpdate(), 完成后 reload // - 显示当前 hotswap 状态 (是否已应用覆盖包)
// - [回滚]: 清除覆盖包, 回到内置 dist // - [检查更新]: 走 checkOtaUpdate()
export default function MePage() { // - [立即升级]: 走 applyOtaUpdate(), 完成后 reload
const { user, logout } = useAuth() // - [回滚]: 清除覆盖包, 回到内置 dist
const navigate = useNavigate() export default function MePage() {
const { user, logout } = useAuth()
// 顶部安全区与「我」页蓝色渐变头部无缝衔接: 给 body 打标记, const navigate = useNavigate()
// 由 CSS 让 .app-safe-top 也使用同一条渐变, 避免出现一条白边/色块把蓝色割断.
// 离开本页时移除标记, 其他页恢复默认底色. // 顶部安全区与「我」页蓝色渐变头部无缝衔接: 给 body 打标记,
useEffect(() => { // 由 CSS 让 .app-safe-top 也使用同一条渐变, 避免出现一条白边/色块把蓝色割断.
document.body.classList.add('me-safe-top') // 离开本页时移除标记, 其他页恢复默认底色.
return () => { document.body.classList.remove('me-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 // OTA 状态
const [otaVersion, setOtaVersion] = useState(null) // hotswap 当前激活的覆盖包版本 const [otaState, setOtaState] = useState('idle') // idle | checking | available | none | downloading | ready | error
const [progress, setProgress] = useState({ received: 0, total: 0 }) const [otaInfo, setOtaInfo] = useState(null) // 检查结果中的 info
const [error, setError] = useState('') const [otaVersion, setOtaVersion] = useState(null) // hotswap 当前激活的覆盖包版本
// 判新模式: false(默认)=版本号比较, true=build sequence 比较; 值持久化在设备端 const [progress, setProgress] = useState({ received: 0, total: 0 })
const [devMode, setDevMode] = useState(() => getOtaDevMode()) const [shellVersion, setShellVersion] = useState('') // 外壳 (APK/桌面包) 版本, 独立维护
// 进入页面时拉一次 hotswap 状态 (Tauri 内才需要, web 下 null 也无所谓) // 外壳版本: 经 Tauri API 读取 (Web 端为空, 底部显示 —)
useEffect(() => { useEffect(() => {
let cancelled = false if (!inTauri) return
getOtaVersionInfo().then((v) => { import('@tauri-apps/api/app')
if (!cancelled) setOtaVersion(v) .then((m) => m.getVersion())
}) .then(setShellVersion)
return () => { cancelled = true } .catch((e) => console.warn('[shell] getVersion failed:', e))
}, []) }, [])
const [error, setError] = useState('')
async function onCheck() { // 判新模式: false(默认)=版本号比较, true=build sequence 比较; 值持久化在设备端
setError('') const [devMode, setDevMode] = useState(() => getOtaDevMode())
setOtaState('checking')
try { // 进入页面时拉一次 hotswap 状态 (Tauri 内才需要, web 下 null 也无所谓)
const r = await checkOtaUpdate({ devMode }) useEffect(() => {
if (r.available) { let cancelled = false
setOtaInfo(r.info) getOtaVersionInfo().then((v) => {
setOtaState('available') if (!cancelled) setOtaVersion(v)
Toast.show({ icon: 'success', content: '发现新版本 v' + (r.info?.version || '') }) })
} else { return () => { cancelled = true }
setOtaInfo(null) }, [])
setOtaState('none')
if (r.reason === 'same_version') { async function onCheck() {
Toast.show({ content: '已是最新版本' }) setError('')
} else if (r.reason === 'no_package') { setOtaState('checking')
Toast.show({ content: '当前没有可用更新' }) try {
} else { const r = await checkOtaUpdate({ devMode })
Toast.show({ content: '当前没有可用更新' }) if (r.available) {
} setOtaInfo(r.info)
} setOtaState('available')
} catch (e) { Toast.show({ icon: 'success', content: '发现新版本 v' + (r.info?.version || '') })
setError(e?.message || '检查失败') } else {
setOtaState('error') setOtaInfo(null)
Toast.show({ icon: 'fail', content: '检查失败: ' + (e?.message || '') }) setOtaState('none')
} if (r.reason === 'same_version') {
} Toast.show({ content: '已是最新版本' })
} else if (r.reason === 'no_package') {
async function onApply() { Toast.show({ content: '当前没有可用更新' })
setError('') } else {
setOtaState('downloading') Toast.show({ content: '当前没有可用更新' })
setProgress({ received: 0, total: 0 }) }
try { }
const newVer = await applyOtaUpdate((received, total) => { } catch (e) {
setProgress({ received, total }) setError(e?.message || '检查失败')
}) setOtaState('error')
Toast.show({ icon: 'success', content: '已下载 v' + newVer + ', 即将重启' }) Toast.show({ icon: 'fail', content: '检查失败: ' + (e?.message || '') })
setOtaState('ready') }
// 给用户 1s 看提示, 然后 reload }
setTimeout(() => reloadApp(), 1000)
} catch (e) { async function onApply() {
setError(e?.message || '升级失败') setError('')
setOtaState('error') setOtaState('downloading')
Toast.show({ icon: 'fail', content: '升级失败: ' + (e?.message || '') }) setProgress({ received: 0, total: 0 })
} try {
} const newVer = await applyOtaUpdate((received, total) => {
setProgress({ received, total })
async function onRollback() { })
if (!window.confirm('确认回滚到内置版本?')) return Toast.show({ icon: 'success', content: '已下载 v' + newVer + ', 即将重启' })
try { setOtaState('ready')
await rollbackOta() // 给用户 1s 看提示, 然后 reload
Toast.show({ icon: 'success', content: '已回滚, 正在重启' }) setTimeout(() => reloadApp(), 1000)
setTimeout(() => reloadApp(), 800) } catch (e) {
} catch (e) { setError(e?.message || '升级失败')
Toast.show({ icon: 'fail', content: '回滚失败: ' + (e?.message || '') }) setOtaState('error')
} Toast.show({ icon: 'fail', content: '升级失败: ' + (e?.message || '') })
} }
}
async function onLogout() {
const confirmed = window.confirm('确定退出登录?') async function onRollback() {
if (!confirmed) return if (!window.confirm('确认回滚到内置版本?')) return
await logout() try {
Toast.show({ icon: 'success', content: '已退出' }) await rollbackOta()
navigate('/login', { replace: true }) Toast.show({ icon: 'success', content: '已回滚, 正在重启' })
} setTimeout(() => reloadApp(), 800)
} catch (e) {
const cur = getCurrentVersionInfo() Toast.show({ icon: 'fail', content: '回滚失败: ' + (e?.message || '') })
const isTauri = typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window }
const showProgress = otaState === 'downloading' && progress.total > 0 }
const progressPct = showProgress ? Math.min(100, Math.round((progress.received / progress.total) * 100)) : 0
async function onLogout() {
return ( const confirmed = window.confirm('确定退出登录?')
<div className="me-page"> if (!confirmed) return
<div className="me-profile"> await logout()
<Avatar Toast.show({ icon: 'success', content: '已退出' })
className="me-avatar" navigate('/login', { replace: true })
src={user?.avatar} }
style={{ '--size': '64px', '--border-radius': '50%' }}
> const cur = getCurrentVersionInfo()
{(user?.name || '?').slice(0, 1).toUpperCase()} const isTauri = typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window
</Avatar> const showProgress = otaState === 'downloading' && progress.total > 0
<div className="me-name">{user?.name || '用户'}</div> const progressPct = showProgress ? Math.min(100, Math.round((progress.received / progress.total) * 100)) : 0
<div className="me-email">{user?.email}</div>
</div> return (
<div className="me-page">
{/* OTA 升级区 */} <div className="me-profile">
<List className="me-list" mode="card" header="前端升级 (OTA)"> <Avatar
<List.Item extra={'v' + cur.version + ' · ' + cur.build}>当前版本</List.Item> className="me-avatar"
<List.Item extra={otaVersion?.active ? 'v' + (otaVersion.version || '?') : '内置'}> src={user?.avatar}
运行资源 style={{ '--size': '64px', '--border-radius': '50%' }}
</List.Item> >
{/* 判新口径开关: 默认关(版本号比较), 打开后按 build sequence 比较 */} {(user?.name || '?').slice(0, 1).toUpperCase()}
<List.Item </Avatar>
extra={ <div className="me-name">{user?.name || '用户'}</div>
<Switch <div className="me-email">{user?.email}</div>
checked={devMode} </div>
onChange={(v) => {
setDevMode(v) {/* OTA 升级区 */}
setOtaDevMode(v) <List className="me-list" mode="card" header="前端升级 (OTA)">
// 切换口径后旧检查结果失效, 重置避免误导 <List.Item extra={'v' + cur.version + (cur.build ? ' · build ' + cur.build : '')}>当前版本</List.Item>
setOtaState('idle') <List.Item extra={otaVersion?.active ? 'v' + (otaVersion.version || '?') : '内置'}>
setOtaInfo(null) 运行资源
setError('') </List.Item>
Toast.show({ content: v ? '开发版本: 按构建号判新' : '正式口径: 按版本号判新' }) {/* 判新口径开关: 默认关(版本号比较), 打开后按 build sequence 比较 */}
}} <List.Item
/> extra={
} <Switch
description={ checked={devMode}
devMode onChange={(v) => {
? '按 build sequence 判新:每次构建都算新版本,便于开发联调' setDevMode(v)
: '按版本号判新:仅当服务端版本号更高时才提示更新' setOtaDevMode(v)
} // 切换口径后旧检查结果失效, 重置避免误导
> setOtaState('idle')
开发版本 setOtaInfo(null)
</List.Item> setError('')
<List.Item> Toast.show({ content: v ? '开发版本: 按构建号判新' : '正式口径: 按版本号判新' })
<div className="me-ota-actions"> }}
<Button />
size="small" }
color="primary" description={
loading={otaState === 'checking'} devMode
disabled={otaState === 'downloading'} ? '按 build sequence 判新:每次构建都算新版本,便于开发联调'
onClick={onCheck} : '按版本号判新:仅当服务端版本号更高时才提示更新'
> }
检查更新 >
</Button> 开发版本
<Button </List.Item>
size="small" <List.Item>
color="danger" <div className="me-ota-actions">
disabled={otaState !== 'available' || otaState === 'downloading'} <Button
onClick={onApply} size="small"
> color="primary"
立即升级 loading={otaState === 'checking'}
</Button> disabled={otaState === 'downloading'}
{isTauri && otaVersion?.active && ( onClick={onCheck}
<Button >
size="small" 检查更新
fill="outline" </Button>
onClick={onRollback} <Button
disabled={otaState === 'downloading'} size="small"
> color="danger"
回滚 disabled={otaState !== 'available' || otaState === 'downloading'}
</Button> onClick={onApply}
)} >
</div> 立即升级
</List.Item> </Button>
{otaState === 'available' && otaInfo && ( {isTauri && otaVersion?.active && (
<List.Item extra={'v' + otaInfo.version}> <Button
新版本 size="small"
{otaInfo.bundle_size ? ` (${(otaInfo.bundle_size / 1024).toFixed(0)} KB)` : ''} fill="outline"
</List.Item> onClick={onRollback}
)} disabled={otaState === 'downloading'}
{otaState === 'downloading' && ( >
<List.Item> 回滚
<div className="me-ota-progress"> </Button>
<div className="me-ota-progress-text"> )}
正在下载 {showProgress ? progressPct + '%' : ''} </div>
{' '}({(progress.received / 1024).toFixed(0)} KB </List.Item>
{progress.total ? ' / ' + (progress.total / 1024).toFixed(0) + ' KB' : ''}) {otaState === 'available' && otaInfo && (
</div> <List.Item extra={'v' + otaInfo.version + (otaInfo.build ? ' · build ' + otaInfo.build : '')}>
<ProgressBar percent={progressPct || undefined} /> 新版本
</div> {otaInfo.bundle_size ? ` (${(otaInfo.bundle_size / 1024).toFixed(0)} KB)` : ''}
</List.Item> </List.Item>
)} )}
{otaInfo?.notes && ( {otaState === 'downloading' && (
<List.Item description={otaInfo.notes}>发布说明</List.Item> <List.Item>
)} <div className="me-ota-progress">
{error && ( <div className="me-ota-progress-text">
<List.Item> 正在下载 {showProgress ? progressPct + '%' : ''}
<span className="me-ota-error">{error}</span> {' '}({(progress.received / 1024).toFixed(0)} KB
</List.Item> {progress.total ? ' / ' + (progress.total / 1024).toFixed(0) + ' KB' : ''})
)} </div>
{!isTauri && ( <ProgressBar percent={progressPct || undefined} />
<List.Item description="Web 端仅展示提示, 实际升级需在 Tauri 客户端进行">提示</List.Item> </div>
)} </List.Item>
</List> )}
{otaInfo?.notes && (
<List className="me-list" mode="card"> <List.Item description={otaInfo.notes}>发布说明</List.Item>
<List.Item extra={'#' + (user?.id || '-')}>用户 ID</List.Item> )}
<List.Item extra={user?.createdAt ? new Date(user.createdAt).toLocaleDateString() : '-'}> {otaInfo?.commit && (
注册时间 <List.Item extra={String(otaInfo.commit).slice(0, 10)}>提交</List.Item>
</List.Item> )}
<List.Item {error && (
onClick={onLogout} <List.Item>
clickable <span className="me-ota-error">{error}</span>
arrowIcon={false} </List.Item>
className="me-logout" )}
> {!isTauri && (
退出登录 <List.Item description="Web 端仅展示提示, 实际升级需在 Tauri 客户端进行">提示</List.Item>
</List.Item> )}
</List> </List>
<div className="me-version">v{APP_VERSION} · build {BUILD_ID}</div>
</div> <List className="me-list" mode="card">
) <List.Item extra={'#' + (user?.id || '-')}>用户 ID</List.Item>
} <List.Item extra={user?.createdAt ? new Date(user.createdAt).toLocaleDateString() : '-'}>
注册时间
</List.Item>
<List.Item
onClick={onLogout}
clickable
arrowIcon={false}
className="me-logout"
>
退出登录
</List.Item>
</List>
<div className="me-version">外壳 v{shellVersion || '—'}</div>
</div>
)
}
+338 -282
View File
@@ -1,282 +1,338 @@
import { useCallback, useEffect, useMemo, useState } from 'react' import { useCallback, useEffect, useMemo, useState } from 'react'
import { import {
Button, Dialog, DotLoading, Empty, Input, Popup, Selector, Stepper, Toast, Button, Dialog, DotLoading, Empty, Input, Popup, Selector, Stepper, Toast,
} from 'antd-mobile' } from 'antd-mobile'
import RingProgress, { Celebrate } from '../../components/RingProgress.jsx' import RingProgress, { Celebrate } from '../../components/RingProgress.jsx'
import { checkinApi } from '../../api/checkin.js' import useHabitDrag from '../../components/checkin/useHabitDrag.js'
import useRefreshOnVisible from '../../hooks/useRefreshOnVisible.js' import { checkinApi } from '../../api/checkin.js'
import { toDateStr, dayLabel } from './date.js' import useRefreshOnVisible from '../../hooks/useRefreshOnVisible.js'
import { toDateStr, dayLabel } from './date.js'
const PERIOD_OPTIONS = [
{ label: '每天', value: 'day' }, const PERIOD_OPTIONS = [
{ label: '每周', value: 'week' }, { label: '每天', value: 'day' },
{ label: '每月', value: 'month' }, { label: '每周', value: 'week' },
{ label: '每年', value: 'year' }, { label: '每月', value: 'month' },
] { label: '每年', value: 'year' },
]
const COLORS = ['#1677ff', '#00b578', '#ff8f1f', '#ff3141', '#a855f7', '#14b8a6', '#f43f9e']
const COLORS = ['#1677ff', '#00b578', '#ff8f1f', '#ff3141', '#a855f7', '#14b8a6', '#f43f9e']
const EMPTY_FORM = { id: null, name: '', period: 'day', target: 1, color: COLORS[0], note: '' }
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 } export default function HabitView({ date, onDateChange }) {
const [err, setErr] = useState('') const [data, setData] = useState(null) // { today, date, items, summary }
const [bursts, setBursts] = useState({}) // { habitId: 触发计数 } const [err, setErr] = useState('')
const [form, setForm] = useState(EMPTY_FORM) const [bursts, setBursts] = useState({}) // { habitId: 触发计数 }
const [showForm, setShowForm] = useState(false) const [form, setForm] = useState(EMPTY_FORM)
const [showForm, setShowForm] = useState(false)
const load = useCallback((silent) => {
if (!silent) setErr('') const load = useCallback((silent) => {
checkinApi.habits(date) if (!silent) setErr('')
.then((d) => setData(d)) checkinApi.habits(date)
.catch((e) => { if (!silent) setErr(e.message) }) .then((d) => setData(d))
}, [date]) .catch((e) => { if (!silent) setErr(e.message) })
}, [date])
useEffect(() => { load(false) }, [load])
useRefreshOnVisible(() => load(true)) useEffect(() => { load(false) }, [load])
useRefreshOnVisible(() => load(true))
const items = useMemo(() => {
const list = data?.items || [] const items = useMemo(() => {
// 未完成在前, 已完成在后 (F-CK-16 默认排序) const list = data?.items || []
return [...list].sort((a, b) => Number(a.completed) - Number(b.completed) || a.id - b.id) // 手动排序优先 (sortOrder), 拖动排序后覆盖 F-CK-16 的"未完成在前"
}, [data]) return [...list].sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0) || a.id - b.id)
}, [data])
const fireBurst = (id) => setBursts((m) => ({ ...m, [id]: (m[id] || 0) + 1 }))
// 按周期分组 (每天/每周/每月/每年), 组内顺序 = 手动排序
// 乐观更新: 先本地 +1, 成功用服务端结果覆盖, 失败回滚 (F-SY-06/E-02) const groups = useMemo(() => {
async function doCheck(item) { const out = []
const before = data for (const p of PERIOD_OPTIONS) {
const patch = (it) => { const list = items.filter((i) => i.period === p.value)
const done = it.done + 1 if (list.length) out.push({ key: p.value, label: p.label, list })
return { }
...it, const known = new Set(PERIOD_OPTIONS.map((p) => p.value))
done, const rest = items.filter((i) => !known.has(i.period))
remaining: Math.max(0, it.target - done), if (rest.length) out.push({ key: 'other', label: '其他', list: rest })
percent: Math.min(1, done / it.target), return out
completed: done >= it.target, }, [items])
over: Math.max(0, done - it.target),
countOnDate: (it.countOnDate || 0) + 1, const drag = useHabitDrag({ onDrop: reorderHabit })
dayDone: (it.countOnDate || 0) + 1 >= it.expectedPerDay,
} // 组内拖动落位: 重排后按展平顺序整体重写 sortOrder
} async function reorderHabit(dragged, targetId, after) {
setData((d) => ({ ...d, items: d.items.map((x) => (x.id === item.id ? patch(x) : x)) })) const snapshot = data
try { const g = groups.find((x) => x.key === dragged.period)
const res = await checkinApi.check(item.id, date) if (!g) return
setData((d) => ({ ...d, items: d.items.map((x) => (x.id === item.id ? res.item : x)) })) const rest = g.list.filter((i) => i.id !== dragged.id)
if (res.item.completed) fireBurst(item.id) const ti = rest.findIndex((i) => i.id === targetId)
} catch (e) { if (ti < 0) return
setData(before) rest.splice(after ? ti + 1 : ti, 0, dragged)
Toast.show({ content: e.message || '打卡失败' }) const flat = groups.map((x) => (x.key === g.key ? rest : x.list)).flat()
} const withOrder = flat.map((it, i) => ({ ...it, sortOrder: i }))
} setData((d) => ({ ...d, items: withOrder }))
try {
async function doUncheck(item) { await checkinApi.reorderHabits(flat.map((i) => i.id))
const before = data } catch (e) {
setData((d) => ({ setData(snapshot)
...d, Toast.show({ content: e.message || '排序保存失败' })
items: d.items.map((x) => { }
if (x.id !== item.id) return x }
const done = Math.max(0, x.done - 1)
return { const fireBurst = (id) => setBursts((m) => ({ ...m, [id]: (m[id] || 0) + 1 }))
...x,
done, // 乐观更新: 先本地 +1, 成功用服务端结果覆盖, 失败回滚 (F-SY-06/E-02)
remaining: Math.max(0, x.target - done), async function doCheck(item) {
percent: Math.min(1, done / x.target), const before = data
completed: done >= x.target, const patch = (it) => {
over: Math.max(0, done - x.target), const done = it.done + 1
countOnDate: Math.max(0, (x.countOnDate || 0) - 1), return {
} ...it,
}), done,
})) remaining: Math.max(0, it.target - done),
try { percent: Math.min(1, done / it.target),
const res = await checkinApi.uncheck(item.id, date) completed: done >= it.target,
setData((d) => ({ ...d, items: d.items.map((x) => (x.id === item.id ? res.item : x)) })) over: Math.max(0, done - it.target),
} catch (e) { countOnDate: (it.countOnDate || 0) + 1,
setData(before) dayDone: (it.countOnDate || 0) + 1 >= it.expectedPerDay,
Toast.show({ content: e.message || '撤销失败' }) }
} }
} setData((d) => ({ ...d, items: d.items.map((x) => (x.id === item.id ? patch(x) : x)) }))
try {
async function submitForm() { const res = await checkinApi.check(item.id, date)
const name = String(form.name || '').trim() setData((d) => ({ ...d, items: d.items.map((x) => (x.id === item.id ? res.item : x)) }))
if (!name) return Toast.show({ content: '请填写名称' }) if (res.item.completed) fireBurst(item.id)
if (name.length > 20) return Toast.show({ content: '名称不能超过 20 个字' }) } catch (e) {
const payload = { name, period: form.period, target: Number(form.target) || 1, color: form.color, note: form.note } setData(before)
try { Toast.show({ content: e.message || '打卡失败' })
if (form.id) await checkinApi.updateHabit(form.id, payload) }
else await checkinApi.createHabit(payload) }
setShowForm(false)
setForm(EMPTY_FORM) async function doUncheck(item) {
load(true) const before = data
} catch (e) { setData((d) => ({
Toast.show({ content: e.message || '保存失败' }) ...d,
} items: d.items.map((x) => {
} if (x.id !== item.id) return x
const done = Math.max(0, x.done - 1)
async function removeHabit(item) { return {
const ok = await Dialog.confirm({ ...x,
content: `归档「${item.name}」?归档后不再出现在待办,历史记录保留。`, done,
confirmText: '归档', remaining: Math.max(0, x.target - done),
cancelText: '取消', percent: Math.min(1, done / x.target),
}) completed: done >= x.target,
if (!ok) return over: Math.max(0, done - x.target),
try { countOnDate: Math.max(0, (x.countOnDate || 0) - 1),
await checkinApi.deleteHabit(item.id) }
Toast.show({ content: '已归档' }) }),
load(true) }))
} catch (e) { try {
Toast.show({ content: e.message || '操作失败' }) 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)
const summary = data?.summary || { total: 0, done: 0 } Toast.show({ content: e.message || '撤销失败' })
const isToday = data ? data.date === data.today : true }
}
return (
<div className="habit-view"> async function submitForm() {
<div className="habit-head"> const name = String(form.name || '').trim()
<div className="habit-head-left"> if (!name) return Toast.show({ content: '请填写名称' })
<div className="habit-date">{dayLabel(date, data?.today || toDateStr())}</div> if (name.length > 20) return Toast.show({ content: '名称不能超过 20 个字' })
<div className="habit-summary"> const payload = { name, period: form.period, target: Number(form.target) || 1, color: form.color, note: form.note }
已完成 <strong>{summary.done}</strong> / {summary.total} try {
{!isToday && <span className="habit-badge">非今天</span>} if (form.id) await checkinApi.updateHabit(form.id, payload)
</div> else await checkinApi.createHabit(payload)
</div> setShowForm(false)
<div className="habit-head-right"> setForm(EMPTY_FORM)
<Button size="mini" color="primary" onClick={() => { setForm(EMPTY_FORM); setShowForm(true) }}> load(true)
+ 新建 } catch (e) {
</Button> Toast.show({ content: e.message || '保存失败' })
</div> }
</div> }
{err && ( async function removeHabit(item) {
<div className="error-banner"> const ok = await Dialog.confirm({
{err} content: `归档「${item.name}」?归档后不再出现在待办,历史记录保留。`,
<span className="retry-link" onClick={() => load(false)}>重试</span> confirmText: '归档',
</div> cancelText: '取消',
)} })
if (!ok) return
{data === null && !err && <div className="monitor-loading"><DotLoading /> 加载中...</div>} try {
await checkinApi.deleteHabit(item.id)
{data !== null && !err && items.length === 0 && ( Toast.show({ content: '已归档' })
<div className="checkin-empty"> load(true)
<Empty description="还没有打卡项" /> } catch (e) {
<Button color="primary" size="small" onClick={() => { setForm(EMPTY_FORM); setShowForm(true) }}> Toast.show({ content: e.message || '操作失败' })
创建第一个打卡项 }
</Button> }
</div>
)} const summary = data?.summary || { total: 0, done: 0 }
const isToday = data ? data.date === data.today : true
<div className="habit-list">
{items.map((item) => ( return (
<div key={item.id} className={'habit-card' + (item.completed ? ' done' : '')}> <div className="habit-view">
<RingProgress <div className="habit-head">
percent={item.percent} <div className="habit-head-left">
done={item.done} <div className="habit-date">{dayLabel(date, data?.today || toDateStr())}</div>
target={item.target} <div className="habit-summary">
over={item.over} 已完成 <strong>{summary.done}</strong> / {summary.total}
completed={item.completed} {!isToday && <span className="habit-badge">非今天</span>}
color={item.color} </div>
onClick={() => doCheck(item)} </div>
/> <div className="habit-head-right">
<div className="habit-main"> <Button size="mini" color="primary" onClick={() => { setForm(EMPTY_FORM); setShowForm(true) }}>
<div className="habit-name">{item.name}</div> + 新建
<div className="habit-meta"> </Button>
{item.periodLabel} {item.target} 次 </div>
{item.streak > 0 && <span className="habit-streak">连续 {item.streak}</span>} </div>
</div>
{item.note && <div className="habit-note">{item.note}</div>} {err && (
<div className="habit-range"> <div className="error-banner">
{item.periodStart === item.periodEnd {err}
? '今日' <span className="retry-link" onClick={() => load(false)}>重试</span>
: `${item.periodStart.slice(5)} ~ ${item.periodEnd.slice(5)}`} </div>
{item.over > 0 && <span className="habit-over">超额 {item.over}</span>} )}
</div>
</div> {data === null && !err && <div className="monitor-loading"><DotLoading /> 加载中...</div>}
<div className="habit-actions">
<Button {data !== null && !err && items.length === 0 && (
size="mini" <div className="checkin-empty">
color={item.completed ? 'default' : 'primary'} <Empty description="还没有打卡项" />
onClick={() => doCheck(item)} <Button color="primary" size="small" onClick={() => { setForm(EMPTY_FORM); setShowForm(true) }}>
> 创建第一个打卡项
{item.completed ? '再打一次' : '打卡'} </Button>
</Button> </div>
</div> )}
<div className="habit-foot">
{item.done > 0 && ( <div className="habit-list">
<span className="habit-link" onClick={() => doUncheck(item)}>撤销</span> {groups.map((g) => (
)} <div key={g.key}>
<span <div className="habit-group-title">
className="habit-link" {g.label}
onClick={() => { <span className="cnt">{g.list.length}</span>
setForm({ id: item.id, name: item.name, period: item.period, target: item.target, color: item.color, note: item.note }) </div>
setShowForm(true) {g.list.map((item) => (
}} <div
>编辑</span> key={item.id}
<span className="habit-link danger" onClick={() => removeHabit(item)}>归档</span> data-habit-row
</div> data-habit-id={item.id}
<Celebrate fire={bursts[item.id] || 0} color={item.color} /> className={
</div> 'habit-card'
))} + (item.completed ? ' done' : '')
</div> + (drag.dragItem?.id === item.id ? ' dragging' : '')
+ (drag.over?.id === item.id ? ` dragover ${drag.over.after ? 'after' : 'before'}` : '')
{/* 新建 / 编辑 */} }
<Popup {...drag.bind(item)}
visible={showForm} >
onMaskClick={() => setShowForm(false)} <RingProgress
bodyStyle={{ borderTopLeftRadius: 12, borderTopRightRadius: 12, paddingBottom: 16 }} percent={item.percent}
> done={item.done}
<div className="form-popup"> target={item.target}
<div className="form-title">{form.id ? '编辑打卡项' : '新建打卡项'}</div> over={item.over}
<div className="form-field"> completed={item.completed}
<label>名称</label> color={item.color}
<Input onClick={() => { if (!drag.justEnded()) doCheck(item) }}
placeholder="例如:喝水 / 健身 / 读书" />
value={form.name} <div className="habit-main">
maxLength={20} <div className="habit-name">{item.name}</div>
onChange={(v) => setForm((f) => ({ ...f, name: v }))} <div className="habit-meta">
/> {item.periodLabel} {item.target} 次
</div> {item.streak > 0 && <span className="habit-streak">连续 {item.streak}</span>}
<div className="form-field"> </div>
<label>周期</label> {item.note && <div className="habit-note">{item.note}</div>}
<Selector <div className="habit-range">
options={PERIOD_OPTIONS} {item.periodStart === item.periodEnd
value={[form.period]} ? '今日'
onChange={(v) => setForm((f) => ({ ...f, period: v[0] }))} : `${item.periodStart.slice(5)} ~ ${item.periodEnd.slice(5)}`}
/> {item.over > 0 && <span className="habit-over">超额 {item.over}</span>}
</div> </div>
<div className="form-field"> </div>
<label>目标次数(周期内)</label> <div className="habit-actions">
<Stepper min={1} max={999} value={form.target} onChange={(v) => setForm((f) => ({ ...f, target: v }))} /> <Button
</div> size="mini"
<div className="form-field"> color={item.completed ? 'default' : 'primary'}
<label>颜色</label> onClick={() => { if (!drag.justEnded()) doCheck(item) }}
<div className="color-row"> >
{COLORS.map((c) => ( {item.completed ? '再打一次' : '打卡'}
<span </Button>
key={c} </div>
className={'color-dot' + (form.color === c ? ' active' : '')} <div className="habit-foot">
style={{ background: c }} {item.done > 0 && (
onClick={() => setForm((f) => ({ ...f, color: c }))} <span className="habit-link" onClick={() => { if (!drag.justEnded()) doUncheck(item) }}>撤销</span>
/> )}
))} <span
</div> className="habit-link"
</div> onClick={() => {
<div className="form-field"> if (drag.justEnded()) return
<label>备注(可选)</label> setForm({ id: item.id, name: item.name, period: item.period, target: item.target, color: item.color, note: item.note })
<Input setShowForm(true)
placeholder="补充说明" }}
value={form.note} >编辑</span>
onChange={(v) => setForm((f) => ({ ...f, note: v }))} <span className="habit-link danger" onClick={() => { if (!drag.justEnded()) removeHabit(item) }}>归档</span>
/> </div>
</div> <Celebrate fire={bursts[item.id] || 0} color={item.color} />
<div className="form-actions"> </div>
<Button block color="primary" onClick={submitForm}>保存</Button> ))}
<Button block fill="outline" onClick={() => setShowForm(false)} style={{ marginTop: 8 }}>取消</Button> </div>
</div> ))}
</div> </div>
</Popup>
</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>
)
}
+461 -416
View File
@@ -1,416 +1,461 @@
// 打卡 · 日历与打卡合并视图 (V0.2.1 界面调整, 设计文档 §11.5) // 打卡 · 日历与打卡合并视图 (V0.2.1 界面调整, 设计文档 §11.5)
// - 上: 月历 (月份切换 + 回到今天), 去掉「昨天/今天/明天」分段 // - 上: 月历 (月份切换 + 回到今天), 去掉「昨天/今天/明天」分段
// - 下: 当日打卡项, 按 每天 / 每周 / 每月 分组 (去掉「每年」分组) // - 下: 当日打卡项, 按 每天 / 每周 / 每月 分组 (去掉「每年」分组)
// - 卡片紧凑化: 内边距 8px, 环形 42px/环宽 5, 间距 7px // - 卡片紧凑化: 内边距 8px, 环形 42px/环宽 5, 间距 7px
// - 撤销 / 编辑 / 归档 收进「⋯」底部面板 // - 撤销 / 编辑 / 归档 收进「⋯」底部面板
// - 未来日期只读, 按钮灰显「计划」 // - 未来日期只读, 按钮灰显「计划」
import { useCallback, useEffect, useMemo, useState } from 'react' import { useCallback, useEffect, useMemo, useState } from 'react'
import { import {
Button, Dialog, DotLoading, Empty, Input, Popup, Selector, Stepper, Toast, Button, Dialog, DotLoading, Empty, Input, Popup, Selector, Stepper, Toast,
} from 'antd-mobile' } from 'antd-mobile'
import RingProgress, { Celebrate } from '../../components/RingProgress.jsx' import RingProgress, { Celebrate } from '../../components/RingProgress.jsx'
import { checkinApi } from '../../api/checkin.js' import useHabitDrag from '../../components/checkin/useHabitDrag.js'
import useRefreshOnVisible from '../../hooks/useRefreshOnVisible.js' import { checkinApi } from '../../api/checkin.js'
import { import useRefreshOnVisible from '../../hooks/useRefreshOnVisible.js'
toDateStr, addDays, addMonths, monthGrid, weekdayLabels, dayLabel, fmtMonth, import {
} from './date.js' toDateStr, addDays, addMonths, monthGrid, weekdayLabels, dayLabel, fmtMonth,
} from './date.js'
// 分组顺序: 每天 → 每周 → 每月 (去掉每年)
const GROUP_ORDER = ['day', 'week', 'month'] // 分组顺序: 每天 → 每周 → 每月 (去掉每年)
const GROUP_LABEL = { day: '每天', week: '每周', month: '每月' } const GROUP_ORDER = ['day', 'week', 'month']
const GROUP_LABEL = { day: '每天', week: '每周', month: '每月' }
const PERIOD_OPTIONS = [
{ label: '每天', value: 'day' }, const PERIOD_OPTIONS = [
{ label: '每周', value: 'week' }, { label: '每天', value: 'day' },
{ label: '每月', value: 'month' }, { label: '每周', value: 'week' },
{ label: '每年', value: 'year' }, { 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: '' } 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() export default function MergedView({ date, onDateChange }) {
const [month, setMonth] = useState(() => date.slice(0, 7)) const today = toDateStr()
const [cal, setCal] = useState(null) // 月历数据 { days, rate } const [month, setMonth] = useState(() => date.slice(0, 7))
const [detail, setDetail] = useState(null) // 选中日详情 { today, date, items, summary, isFuture } const [cal, setCal] = useState(null) // 月历数据 { days, rate }
const [err, setErr] = useState('') const [detail, setDetail] = useState(null) // 选中日详情 { today, date, items, summary, isFuture }
const [bursts, setBursts] = useState({}) const [err, setErr] = useState('')
const [sheetItem, setSheetItem] = useState(null) // 「⋯」面板对应的打卡项 const [bursts, setBursts] = useState({})
const [form, setForm] = useState(EMPTY_FORM) const [sheetItem, setSheetItem] = useState(null) // 「⋯」面板对应的打卡项
const [showForm, setShowForm] = useState(false) const [form, setForm] = useState(EMPTY_FORM)
const [showForm, setShowForm] = useState(false)
// ---------- 数据加载 ----------
const loadCal = useCallback((silent) => { // ---------- 数据加载 ----------
if (!silent) setErr('') const loadCal = useCallback((silent) => {
checkinApi.calendar(month) if (!silent) setErr('')
.then(setCal) checkinApi.calendar(month)
.catch((e) => { if (!silent) setErr(e.message) }) .then(setCal)
}, [month]) .catch((e) => { if (!silent) setErr(e.message) })
}, [month])
const loadDetail = useCallback((silent) => {
if (!silent) setErr('') const loadDetail = useCallback((silent) => {
checkinApi.habits(date) if (!silent) setErr('')
.then(setDetail) checkinApi.habits(date)
.catch((e) => { if (!silent) setErr(e.message) }) .then(setDetail)
}, [date]) .catch((e) => { if (!silent) setErr(e.message) })
}, [date])
useEffect(() => { loadCal(false) }, [loadCal])
useEffect(() => { setDetail(null); loadDetail(false) }, [loadDetail]) useEffect(() => { loadCal(false) }, [loadCal])
useRefreshOnVisible(() => { loadCal(true); loadDetail(true) }) useEffect(() => { setDetail(null); loadDetail(false) }, [loadDetail])
useRefreshOnVisible(() => { loadCal(true); loadDetail(true) })
// 切换日期时若跨月, 同步月份
useEffect(() => { // 切换日期时若跨月, 同步月份
const m = date.slice(0, 7) useEffect(() => {
setMonth((cur) => (cur === m ? cur : m)) const m = date.slice(0, 7)
}, [date]) setMonth((cur) => (cur === m ? cur : m))
}, [date])
const days = useMemo(() => monthGrid(month), [month])
const byDate = useMemo(() => { const days = useMemo(() => monthGrid(month), [month])
const map = new Map() const byDate = useMemo(() => {
;(cal?.days || []).forEach((d) => map.set(d.date, d)) const map = new Map()
return map ;(cal?.days || []).forEach((d) => map.set(d.date, d))
}, [cal]) return map
}, [cal])
const items = detail?.items || []
const isFuture = !!detail?.isFuture const items = detail?.items || []
const isFuture = !!detail?.isFuture
// 按周期分组: 每天 → 每周 → 每月. "每年" 不在合并视图中展示, 但若存在
// 历史 year 项则归入"每月"之外的单列, 避免数据凭空消失. // 按周期分组: 每天 → 每周 → 每月. "每年" 不在合并视图中展示, 但若存在
const groups = useMemo(() => { // 历史 year 项则归入"每月"之外的单列, 避免数据凭空消失.
const bucket = new Map() // 组内顺序: 手动排序 (sortOrder) 优先 —— 拖动排序需要稳定口径,
for (const it of items) { // 服务端 /habits 已按 sortOrder 返回, 此处仅兜底。
const key = GROUP_ORDER.includes(it.period) ? it.period : 'other' const groups = useMemo(() => {
if (!bucket.has(key)) bucket.set(key, []) const bucket = new Map()
bucket.get(key).push(it) for (const it of items) {
} const key = GROUP_ORDER.includes(it.period) ? it.period : 'other'
// 组内排序: 未完成在前, 已完成在后 (沿用 F-CK-16 默认口径) if (!bucket.has(key)) bucket.set(key, [])
for (const arr of bucket.values()) { bucket.get(key).push(it)
arr.sort((a, b) => Number(a.completed) - Number(b.completed) || a.id - b.id) }
} for (const arr of bucket.values()) {
const order = [...GROUP_ORDER, 'other'] arr.sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0) || a.id - b.id)
return order.filter((k) => bucket.has(k)).map((k) => ({ }
key: k, const order = [...GROUP_ORDER, 'other']
label: GROUP_LABEL[k] || '其它周期', return order.filter((k) => bucket.has(k)).map((k) => ({
items: bucket.get(k), key: k,
})) label: GROUP_LABEL[k] || '其它周期',
}, [items]) items: bucket.get(k),
}))
const summary = detail?.summary || { total: 0, done: 0 } }, [items])
const fireBurst = (id) => setBursts((m) => ({ ...m, [id]: (m[id] || 0) + 1 }))
const drag = useHabitDrag({ onDrop: reorderHabit })
// ---------- 打卡 / 撤销 (乐观更新) ----------
async function doCheck(item) { // 拖动落位: 组内排序; 落到其他周期组 = 同时改周期并落位。
// 只按"是否未来日期"拦截. 不能判 item.canCheck —— 该字段仅 /day 接口返回, // 重排后按渲染分组顺序整体展平重写 sortOrder。
// 本视图数据来自 /habits (不含 canCheck), 用它会把所有打卡按钮全部禁用. async function reorderHabit(dragged, targetId, after, targetPeriod) {
if (isFuture) return const snapshot = detail
const before = detail const g = groups.find((x) => x.items.some((i) => i.id === targetId))
setDetail((d) => ({ if (!g) return
...d, const target = g.items.find((i) => i.id === targetId)
items: d.items.map((x) => { const rest = g.items.filter((i) => i.id !== dragged.id)
if (x.id !== item.id) return x const ti = rest.findIndex((i) => i.id === targetId)
const done = x.done + 1 if (ti < 0) return
const countOnDate = (x.countOnDate || 0) + 1 const crossed = targetPeriod && targetPeriod !== dragged.period
return { const moved = crossed
...x, ? { ...dragged, period: targetPeriod, periodLabel: GROUP_LABEL[targetPeriod] || target.periodLabel }
done, : dragged
remaining: Math.max(0, x.target - done), rest.splice(after ? ti + 1 : ti, 0, moved)
percent: Math.min(1, done / x.target), // 展平时把拖动项从原组剔除 (跨组场景原组可能仍包含它)
completed: done >= x.target, const flat = groups
over: Math.max(0, done - x.target), .map((x) => (x.key === g.key ? rest : x.items.filter((i) => i.id !== dragged.id)))
countOnDate, .flat()
dayDone: countOnDate >= x.expectedPerDay, setDetail((d) => ({ ...d, items: flat.map((it, i) => ({ ...it, sortOrder: i })) }))
} try {
}), if (crossed) await checkinApi.updateHabit(dragged.id, { period: targetPeriod })
})) await checkinApi.reorderHabits(flat.map((i) => i.id))
try { } catch (e) {
const res = await checkinApi.check(item.id, date) setDetail(snapshot)
const fresh = await checkinApi.habits(date) Toast.show({ content: e.message || '排序保存失败' })
setDetail(fresh) }
if (res.item?.completed) fireBurst(item.id) }
loadCal(true)
} catch (e) { const summary = detail?.summary || { total: 0, done: 0 }
setDetail(before) const fireBurst = (id) => setBursts((m) => ({ ...m, [id]: (m[id] || 0) + 1 }))
Toast.show({ content: e.message || '打卡失败' })
} // ---------- 打卡 / 撤销 (乐观更新) ----------
} async function doCheck(item) {
// 只按"是否未来日期"拦截. 不能判 item.canCheck —— 该字段仅 /day 接口返回,
async function doUncheck(item) { // 本视图数据来自 /habits (不含 canCheck), 用它会把所有打卡按钮全部禁用.
const before = detail if (isFuture) return
setDetail((d) => ({ const before = detail
...d, setDetail((d) => ({
items: d.items.map((x) => { ...d,
if (x.id !== item.id) return x items: d.items.map((x) => {
const done = Math.max(0, x.done - 1) if (x.id !== item.id) return x
const countOnDate = Math.max(0, (x.countOnDate || 0) - 1) const done = x.done + 1
return { const countOnDate = (x.countOnDate || 0) + 1
...x, return {
done, ...x,
remaining: Math.max(0, x.target - done), done,
percent: Math.min(1, done / x.target), remaining: Math.max(0, x.target - done),
completed: done >= x.target, percent: Math.min(1, done / x.target),
over: Math.max(0, done - x.target), completed: done >= x.target,
countOnDate, over: Math.max(0, done - x.target),
dayDone: countOnDate >= x.expectedPerDay, countOnDate,
} dayDone: countOnDate >= x.expectedPerDay,
}), }
})) }),
try { }))
await checkinApi.uncheck(item.id, date) try {
const fresh = await checkinApi.habits(date) const res = await checkinApi.check(item.id, date)
setDetail(fresh) const fresh = await checkinApi.habits(date)
loadCal(true) setDetail(fresh)
} catch (e) { if (res.item?.completed) fireBurst(item.id)
setDetail(before) loadCal(true)
Toast.show({ content: e.message || '撤销失败' }) } catch (e) {
} setDetail(before)
} Toast.show({ content: e.message || '打卡失败' })
}
// ---------- 新建 / 编辑 / 归档 ---------- }
function openCreate() {
setForm(EMPTY_FORM) async function doUncheck(item) {
setShowForm(true) const before = detail
} setDetail((d) => ({
...d,
function openEdit(item) { items: d.items.map((x) => {
setSheetItem(null) if (x.id !== item.id) return x
setForm({ id: item.id, name: item.name, period: item.period, target: item.target, color: item.color, note: item.note }) const done = Math.max(0, x.done - 1)
setShowForm(true) const countOnDate = Math.max(0, (x.countOnDate || 0) - 1)
} return {
...x,
async function submitForm() { done,
const name = String(form.name || '').trim() remaining: Math.max(0, x.target - done),
if (!name) return Toast.show({ content: '请填写名称' }) percent: Math.min(1, done / x.target),
if (name.length > 20) return Toast.show({ content: '名称不能超过 20 个字' }) completed: done >= x.target,
const payload = { name, period: form.period, target: Number(form.target) || 1, color: form.color, note: form.note } over: Math.max(0, done - x.target),
try { countOnDate,
if (form.id) await checkinApi.updateHabit(form.id, payload) dayDone: countOnDate >= x.expectedPerDay,
else await checkinApi.createHabit(payload) }
setShowForm(false) }),
setForm(EMPTY_FORM) }))
loadDetail(true) try {
loadCal(true) await checkinApi.uncheck(item.id, date)
} catch (e) { const fresh = await checkinApi.habits(date)
Toast.show({ content: e.message || '保存失败' }) setDetail(fresh)
} loadCal(true)
} } catch (e) {
setDetail(before)
async function doArchive(item) { Toast.show({ content: e.message || '撤销失败' })
setSheetItem(null) }
const ok = await Dialog.confirm({ }
content: `归档「${item.name}」?归档后不再出现在待办,历史记录保留。`,
confirmText: '归档', // ---------- 新建 / 编辑 / 归档 ----------
cancelText: '取消', function openCreate() {
}) setForm(EMPTY_FORM)
if (!ok) return setShowForm(true)
try { }
await checkinApi.deleteHabit(item.id)
Toast.show({ content: '已归档' }) function openEdit(item) {
loadDetail(true) setSheetItem(null)
loadCal(true) setForm({ id: item.id, name: item.name, period: item.period, target: item.target, color: item.color, note: item.note })
} catch (e) { setShowForm(true)
Toast.show({ content: e.message || '操作失败' }) }
}
} async function submitForm() {
const name = String(form.name || '').trim()
// ---------- 渲染 ---------- if (!name) return Toast.show({ content: '请填写名称' })
return ( if (name.length > 20) return Toast.show({ content: '名称不能超过 20 个字' })
<div className="mv-view"> const payload = { name, period: form.period, target: Number(form.target) || 1, color: form.color, note: form.note }
{/* 月历 */} try {
<div className="mv-cal"> if (form.id) await checkinApi.updateHabit(form.id, payload)
<div className="mv-month"> else await checkinApi.createHabit(payload)
<span className="arrow" onClick={() => setMonth((m) => addMonths(m, -1))}>‹</span> setShowForm(false)
<b>{fmtMonth(month)}</b> setForm(EMPTY_FORM)
<span className="arrow" onClick={() => setMonth((m) => addMonths(m, 1))}>›</span> loadDetail(true)
<span className="today" onClick={() => onDateChange(today)}>回到今天</span> loadCal(true)
</div> } catch (e) {
Toast.show({ content: e.message || '保存失败' })
<div className="mv-grid mv-wd"> }
{weekdayLabels().map((w) => <span key={w}>{w}</span>)} }
</div>
<div className="mv-grid"> async function doArchive(item) {
{days.map((d) => { setSheetItem(null)
const info = byDate.get(d) const ok = await Dialog.confirm({
const inMonth = d.slice(0, 7) === month content: `归档「${item.name}」?归档后不再出现在待办,历史记录保留。`,
const status = info?.status || 'empty' confirmText: '归档',
return ( cancelText: '取消',
<div })
key={d} if (!ok) return
className={ try {
'mv-day' + (inMonth ? '' : ' out') + (d === date ? ' sel' : '') + await checkinApi.deleteHabit(item.id)
(info?.isToday ? ' today' : '') + (info?.isFuture ? ' future' : '') Toast.show({ content: '已归档' })
} loadDetail(true)
onClick={() => onDateChange(d)} loadCal(true)
> } catch (e) {
<span>{Number(d.slice(8, 10))}</span> Toast.show({ content: e.message || '操作失败' })
{inMonth && status !== 'empty' && <i className={status} />} }
</div> }
)
})} // ---------- 渲染 ----------
</div> return (
<div className="mv-view">
{cal && ( {/* 月历 */}
<div className="mv-rate"> <div className="mv-cal">
<span>本月完成率</span> <div className="mv-month">
<b>{Math.round((cal.rate || 0) * 100)}%</b> <span className="arrow" onClick={() => setMonth((m) => addMonths(m, -1))}>‹</span>
</div> <b>{fmtMonth(month)}</b>
)} <span className="arrow" onClick={() => setMonth((m) => addMonths(m, 1))}>›</span>
</div> <span className="today" onClick={() => onDateChange(today)}>回到今天</span>
</div>
{err && <div className="error-banner">{err}<span className="retry-link" onClick={() => { loadCal(false); loadDetail(false) }}>重试</span></div>}
<div className="mv-grid mv-wd">
{/* 当日栏: 右侧放「+ 新建」, 替代原来无实际作用的「点环即打卡」提示 */} {weekdayLabels().map((w) => <span key={w}>{w}</span>)}
<div className="mv-daybar"> </div>
{dayLabel(date, detail?.today || today)} <div className="mv-grid">
{isFuture {days.map((d) => {
? <span className="badge">计划(不可打卡)</span> const info = byDate.get(d)
: <span className="badge">已完成 {summary.done} / {summary.total}</span>} const inMonth = d.slice(0, 7) === month
<Button const status = info?.status || 'empty'
className="mv-new-btn" return (
size="mini" <div
color="primary" key={d}
onClick={openCreate} className={
> 'mv-day' + (inMonth ? '' : ' out') + (d === date ? ' sel' : '') +
+ 新建 (info?.isToday ? ' today' : '') + (info?.isFuture ? ' future' : '')
</Button> }
</div> onClick={() => onDateChange(d)}
>
{detail === null && !err && <div className="monitor-loading"><DotLoading /> 加载中...</div>} <span>{Number(d.slice(8, 10))}</span>
{inMonth && status !== 'empty' && <i className={status} />}
{detail !== null && items.length === 0 && ( </div>
<div className="checkin-empty"> )
<Empty description="当天没有打卡项" /> })}
<Button color="primary" size="small" onClick={openCreate}>创建第一个打卡项</Button> </div>
</div>
)} {cal && (
<div className="mv-rate">
{/* 分组列表 */} <span>本月完成率</span>
<div className="mv-groups"> <b>{Math.round((cal.rate || 0) * 100)}%</b>
{groups.map((g) => ( </div>
<div key={g.key}> )}
<div className="mv-ghead">{g.label}<span className="cnt">{g.items.length} 项</span></div> </div>
{g.items.map((item) => (
<div key={item.id} className={'mv-card' + (item.completed ? ' done' : '')}> {err && <div className="error-banner">{err}<span className="retry-link" onClick={() => { loadCal(false); loadDetail(false) }}>重试</span></div>}
<RingProgress
size={42} {/* 当日栏: 右侧放「+ 新建」, 替代原来无实际作用的「点环即打卡」提示 */}
stroke={5} <div className="mv-daybar">
percent={item.percent} {dayLabel(date, detail?.today || today)}
done={item.done} {isFuture
target={item.target} ? <span className="badge">计划(不可打卡)</span>
over={item.over} : <span className="badge">已完成 {summary.done} / {summary.total}</span>}
completed={item.completed} <Button
color={item.color} className="mv-new-btn"
onClick={() => doCheck(item)} size="mini"
/> color="primary"
<div className="mv-main"> onClick={openCreate}
<div className="mv-name">{item.name}</div> >
<div className="mv-meta"> + 新建
{item.periodLabel} {item.target} 次 · 当日 {item.countOnDate || 0}/{item.expectedPerDay} </Button>
{item.streak > 0 && <span className="st">连续 {item.streak}</span>} </div>
</div>
</div> {detail === null && !err && <div className="monitor-loading"><DotLoading /> 加载中...</div>}
{isFuture ? (
<div className="mv-btn grey">计划</div> {detail !== null && items.length === 0 && (
) : ( <div className="checkin-empty">
<div <Empty description="当天没有打卡项" />
className={'mv-btn' + (item.completed ? ' grey' : '')} <Button color="primary" size="small" onClick={openCreate}>创建第一个打卡项</Button>
onClick={() => doCheck(item)} </div>
> )}
{item.completed ? '再打一次' : '打卡'}
</div> {/* 分组列表 */}
)} <div className="mv-groups">
<div className="mv-more" onClick={() => setSheetItem(item)}>⋯</div> {groups.map((g) => (
<Celebrate fire={bursts[item.id] || 0} color={item.color} /> <div key={g.key}>
</div> <div className="mv-ghead">{g.label}<span className="cnt">{g.items.length} 项</span></div>
))} {g.items.map((item) => (
</div> <div
))} key={item.id}
</div> data-habit-row
data-habit-id={item.id}
{/* 「⋯」操作面板: 撤销 / 编辑 / 归档 */} data-habit-period={item.period}
<Popup className={
visible={!!sheetItem} 'mv-card'
onMaskClick={() => setSheetItem(null)} + (item.completed ? ' done' : '')
bodyStyle={{ borderTopLeftRadius: 12, borderTopRightRadius: 12, paddingBottom: 16 }} + (drag.dragItem?.id === item.id ? ' dragging' : '')
> + (drag.over?.id === item.id ? ` dragover ${drag.over.after ? 'after' : 'before'}` : '')
{sheetItem && ( }
<div className="mv-sheet"> >
<div className="sheet-title">{sheetItem.name}</div> <span className="grip" title="拖动排序" {...drag.bindHandle(item)}>⠿</span>
<div <RingProgress
className={'mv-sheet-row' + (sheetItem.done > 0 ? '' : ' disabled')} size={42}
onClick={() => { if (sheetItem.done > 0) { doUncheck(sheetItem); setSheetItem(null) } }} stroke={5}
> percent={item.percent}
<span>撤销上次打卡</span> done={item.done}
<span className="k">今日 {sheetItem.countOnDate || 0} 次</span> target={item.target}
</div> over={item.over}
<div className="mv-sheet-row" onClick={() => openEdit(sheetItem)}> completed={item.completed}
<span>编辑打卡项</span> color={item.color}
<span className="k">名称 / 周期 / 目标</span> onClick={() => { if (!drag.justEnded()) doCheck(item) }}
</div> />
<div className="mv-sheet-row danger" onClick={() => doArchive(sheetItem)}> <div className="mv-main">
<span>归档</span> <div className="mv-name">{item.name}</div>
<span className="k">历史记录保留</span> <div className="mv-meta">
</div> {item.periodLabel} {item.target} 次 · 当日 {item.countOnDate || 0}/{item.expectedPerDay}
</div> {item.streak > 0 && <span className="st">连续 {item.streak}</span>}
)} </div>
</Popup> </div>
{isFuture ? (
{/* 新建 / 编辑表单 */} <div className="mv-btn grey">计划</div>
<Popup ) : (
visible={showForm} <div
onMaskClick={() => setShowForm(false)} className={'mv-btn' + (item.completed ? ' grey' : '')}
bodyStyle={{ borderTopLeftRadius: 12, borderTopRightRadius: 12, paddingBottom: 16 }} onClick={() => { if (!drag.justEnded()) doCheck(item) }}
> >
<div className="form-popup"> {item.completed ? '再打一次' : '打卡'}
<div className="form-title">{form.id ? '编辑打卡项' : '新建打卡项'}</div> </div>
<div className="form-field"> )}
<label>名称</label> <div className="mv-more" onClick={() => setSheetItem(item)}>⋯</div>
<Input <Celebrate fire={bursts[item.id] || 0} color={item.color} />
placeholder="例如:喝水 / 健身 / 读书" </div>
value={form.name} ))}
maxLength={20} </div>
onChange={(v) => setForm((f) => ({ ...f, name: v }))} ))}
/> </div>
</div>
<div className="form-field"> {/* 「⋯」操作面板: 撤销 / 编辑 / 归档 */}
<label>周期</label> <Popup
<Selector visible={!!sheetItem}
options={PERIOD_OPTIONS} onMaskClick={() => setSheetItem(null)}
value={[form.period]} bodyStyle={{ borderTopLeftRadius: 12, borderTopRightRadius: 12, paddingBottom: 16 }}
onChange={(v) => setForm((f) => ({ ...f, period: v[0] }))} >
/> {sheetItem && (
</div> <div className="mv-sheet">
<div className="form-field"> <div className="sheet-title">{sheetItem.name}</div>
<label>目标次数(周期内)</label> <div
<Stepper min={1} max={999} value={form.target} onChange={(v) => setForm((f) => ({ ...f, target: v }))} /> className={'mv-sheet-row' + (sheetItem.done > 0 ? '' : ' disabled')}
</div> onClick={() => { if (sheetItem.done > 0) { doUncheck(sheetItem); setSheetItem(null) } }}
<div className="form-field"> >
<label>颜色</label> <span>撤销上次打卡</span>
<div className="color-row"> <span className="k">今日 {sheetItem.countOnDate || 0} 次</span>
{COLORS.map((c) => ( </div>
<span <div className="mv-sheet-row" onClick={() => openEdit(sheetItem)}>
key={c} <span>编辑打卡项</span>
className={'color-dot' + (form.color === c ? ' active' : '')} <span className="k">名称 / 周期 / 目标</span>
style={{ background: c }} </div>
onClick={() => setForm((f) => ({ ...f, color: c }))} <div className="mv-sheet-row danger" onClick={() => doArchive(sheetItem)}>
/> <span>归档</span>
))} <span className="k">历史记录保留</span>
</div> </div>
</div> </div>
<div className="form-field"> )}
<label>备注(可选)</label> </Popup>
<Input
placeholder="补充说明" {/* 新建 / 编辑表单 */}
value={form.note} <Popup
onChange={(v) => setForm((f) => ({ ...f, note: v }))} visible={showForm}
/> onMaskClick={() => setShowForm(false)}
</div> bodyStyle={{ borderTopLeftRadius: 12, borderTopRightRadius: 12, paddingBottom: 16 }}
<div className="form-actions"> >
<Button block color="primary" onClick={submitForm}>保存</Button> <div className="form-popup">
<Button block fill="outline" onClick={() => setShowForm(false)} style={{ marginTop: 8 }}>取消</Button> <div className="form-title">{form.id ? '编辑打卡项' : '新建打卡项'}</div>
</div> <div className="form-field">
</div> <label>名称</label>
</Popup> <Input
</div> 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>
)
}
+34 -3
View File
@@ -1,8 +1,39 @@
import { useEffect, useRef, useState } from 'react'
import TaskCard from '../../components/task/TaskCard.jsx' import TaskCard from '../../components/task/TaskCard.jsx'
import useColumnDrag from '../../components/task/useColumnDrag.js' import useColumnDrag from '../../components/task/useColumnDrag.js'
// 列体 + 右缘滚动条: 滚动时出现细滚动条, 停止约 700ms 后淡出
// (移动端 WebView 原生滚动条是即隐式的, 看板列窄、需要明确的滚动指示)
function ScrollBody({ children }) {
const ref = useRef(null)
const fade = useRef(null)
const [bar, setBar] = useState(null) // { h, y } 滚动条几何
useEffect(() => () => clearTimeout(fade.current), [])
function onScroll() {
const el = ref.current
if (!el) return
const max = el.scrollHeight - el.clientHeight
if (max <= 2) { setBar(null); return }
const h = Math.max(28, Math.round(el.clientHeight * (el.clientHeight / el.scrollHeight)))
const y = Math.round((el.scrollTop / max) * (el.clientHeight - h))
setBar({ h, y })
clearTimeout(fade.current)
fade.current = setTimeout(() => setBar(null), 700)
}
return (
<div className="bcol-main">
<div className="bcol-body" ref={ref} onScroll={onScroll}>{children}</div>
<div className={'bcol-bar' + (bar ? ' on' : '')} style={bar ? { top: bar.y, height: bar.h } : undefined} />
</div>
)
}
// 看板视图 (T-BD-01, T-UI-07) // 看板视图 (T-BD-01, T-UI-07)
// 拖动 = 长按 400ms 激活, 卡片原位抬起, 指针划过列高亮, 松手落列 (重点设计 §7.1) // 拖动 = 长按 400ms 激活, 卡片原位抬起, 指针划过列高亮, 松手落列 (重点设计 §7.1)
// 横向滚列; 列内独立纵向滚动, 列头固定, 滚动时右缘显示滚动条
// 不做"拖到卡片上转子任务" —— 统一走详情页「转为子任务」 // 不做"拖到卡片上转子任务" —— 统一走详情页「转为子任务」
export default function BoardView({ export default function BoardView({
tasks, columns, onMoveColumn, onOpen, onToggleSub, onToggle, onMore, onQuickAdd, tasks, columns, onMoveColumn, onOpen, onToggleSub, onToggle, onMore, onQuickAdd,
@@ -33,14 +64,14 @@ export default function BoardView({
)} )}
</div> </div>
<div className="bcol-body"> <ScrollBody>
{list.map((t) => ( {list.map((t) => (
<TaskCard <TaskCard
key={t.id} key={t.id}
task={t} task={t}
dragging={drag.dragTask?.id === t.id} dragging={drag.dragTask?.id === t.id}
bindDrag={drag.bind} bindDrag={drag.bind}
onClick={() => { if (!drag.dragTask) onOpen?.(t) }} onClick={() => { if (!drag.dragTask && !drag.justEnded()) onOpen?.(t) }}
onToggleSub={onToggleSub} onToggleSub={onToggleSub}
onToggle={onToggle} onToggle={onToggle}
onMore={onMore} onMore={onMore}
@@ -48,7 +79,7 @@ export default function BoardView({
))} ))}
{list.length === 0 && <div className="bcol-empty">暂无任务</div>} {list.length === 0 && <div className="bcol-empty">暂无任务</div>}
<div className="badd" onClick={() => onQuickAdd?.(col.id)}>+ 添加任务</div> <div className="badd" onClick={() => onQuickAdd?.(col.id)}>+ 添加任务</div>
</div> </ScrollBody>
</div> </div>
) )
})} })}
+12 -6
View File
@@ -1,13 +1,16 @@
import { useState } from 'react' import { useState } from 'react'
import TaskRow from '../../components/task/TaskRow.jsx' import TaskRow from '../../components/task/TaskRow.jsx'
import useRowDrag from '../../components/task/useRowDrag.js'
// 列表视图 (T-VW-01, T-ST-04/05) // 列表视图 (T-VW-01, T-ST-04/05)
// 按看板列分组展示; 子任务缩进 + 折叠展开 // 按看板列分组展示; 子任务缩进 + 折叠展开
// 子任务的添加只在任务详情页完成 (或把别的任务转为子任务), 列表页不放入口 // 换列: 行/把手拖动, 跟手浮层, 手指划到哪个分组松手就落到哪 (与看板一致)
// (列表只展示有任务的列, 空列需到看板视图拖入)
export default function ListView({ export default function ListView({
tasks, columns, onToggle, onOpen, onMore, onLongPress, tasks, columns, onToggle, onOpen, onMore, onMoveColumn, onReorder,
}) { }) {
const [collapsed, setCollapsed] = useState({}) const [collapsed, setCollapsed] = useState({})
const drag = useRowDrag({ columns, onDrop: onMoveColumn, onReorder })
if (!tasks || tasks.length === 0) { if (!tasks || tasks.length === 0) {
return ( return (
@@ -32,7 +35,7 @@ export default function ListView({
return ( return (
<div className="list-body"> <div className="list-body">
{groups.map((g) => ( {groups.map((g) => (
<div key={g.key}> <div key={g.key} data-column-id={g.key === 'other' ? undefined : g.key}>
<div className="group-title"> <div className="group-title">
{g.name} {g.name}
<span className="cnt">{g.list.length}</span> <span className="cnt">{g.list.length}</span>
@@ -41,19 +44,22 @@ export default function ListView({
{g.list.map((t) => { {g.list.map((t) => {
const kids = t.children || [] const kids = t.children || []
const isCollapsed = !!collapsed[t.id] const isCollapsed = !!collapsed[t.id]
const dragging = drag.drag?.task?.id === t.id ? drag.drag : null
return ( return (
<div key={t.id}> <div key={t.id}>
<TaskRow <TaskRow
task={t} task={t}
onToggle={onToggle} onToggle={onToggle}
onClick={() => onOpen(t)} onClick={() => { if (!drag.justEnded()) onOpen(t) }}
onMore={onMore} onMore={onMore}
onLongPress={onLongPress} bindDragHandle={drag.bindHandle}
dragging={!!dragging}
dragOver={drag.over?.id === t.id ? drag.over : null}
/> />
{kids.length > 0 && ( {kids.length > 0 && (
<> <>
{!isCollapsed && kids.map((c) => ( {!isCollapsed && kids.map((c) => (
<TaskRow key={c.id} task={c} indent onToggle={onToggle} onClick={() => onOpen(c)} onMore={onMore} onLongPress={onLongPress} /> <TaskRow key={c.id} task={c} indent onToggle={onToggle} onClick={() => onOpen(c)} onMore={onMore} />
))} ))}
<div <div
className="collapse" className="collapse"
+155 -5
View File
@@ -1,10 +1,13 @@
import { useState } from 'react' import { useRef, useState } from 'react'
import { Dialog, Input, Popup, Toast } from 'antd-mobile' import { Dialog, Input, Popup, Toast } from 'antd-mobile'
import { projectApi } from '../../api/task.js' import { projectApi } from '../../api/task.js'
import { useAuth } from '../../auth/AuthContext.jsx' import { useAuth } from '../../auth/AuthContext.jsx'
import useKeyboardInset from '../../hooks/useKeyboardInset.js'
import useOverlayBack from '../../hooks/useOverlayBack.js' import useOverlayBack from '../../hooks/useOverlayBack.js'
import ColumnSettings from './ColumnSettings.jsx' import ColumnSettings from './ColumnSettings.jsx'
import ProjectMembers from './ProjectMembers.jsx' import ProjectMembers from './ProjectMembers.jsx'
import { DEFAULT_KINDS, kindColor } from './kind.js'
import { colorOfProject } from './projectIcon.js'
// 项目管理 (T-PJ-11/12): 成员 / 看板列 / 项目信息 三个分区的统一入口 // 项目管理 (T-PJ-11/12): 成员 / 看板列 / 项目信息 三个分区的统一入口
// onExited: 项目被删除/退出/归档后, 上层应离开该项目(回落个人默认项目) // onExited: 项目被删除/退出/归档后, 上层应离开该项目(回落个人默认项目)
@@ -15,6 +18,12 @@ export default function ProjectManage({ project, onClose, onChanged, onExited })
const [name, setName] = useState(project.name) const [name, setName] = useState(project.name)
// 信息块编辑 (T-PJ-13): index=-1 新增, >=0 编辑既有块 // 信息块编辑 (T-PJ-13): index=-1 新增, >=0 编辑既有块
const [noteEdit, setNoteEdit] = useState(null) const [noteEdit, setNoteEdit] = useState(null)
// 任务类型编辑: 编辑中的类型列表 + 新增输入
const [kindEdit, setKindEdit] = useState(false)
const [kinds, setKinds] = useState([])
const [newKind, setNewKind] = useState('')
// 项目图标更换: 隐藏 file input
const iconInputRef = useRef(null)
const members = project.members || [] const members = project.members || []
const notes = Array.isArray(project.notes) ? project.notes : [] const notes = Array.isArray(project.notes) ? project.notes : []
@@ -23,6 +32,8 @@ export default function ProjectManage({ project, onClose, onChanged, onExited })
// 返回键: 内层视图(列设置/成员)先回主区, 主区再关闭整个项目管理 // 返回键: 内层视图(列设置/成员)先回主区, 主区再关闭整个项目管理
useOverlayBack(true, () => { if (view !== 'main') setView('main'); else onClose() }) useOverlayBack(true, () => { if (view !== 'main') setView('main'); else onClose() })
// 键盘弹出插入量: 信息块编辑弹层用
const kb = useKeyboardInset()
if (view === 'columns') { if (view === 'columns') {
return ( return (
@@ -120,8 +131,71 @@ export default function ProjectManage({ project, onClose, onChanged, onExited })
} }
} }
async function quit() { // 任务类型: 添加 / 删除 (至少保留一个)
const ok = await Dialog.confirm({ function addKind() {
const k = newKind.trim().slice(0, 20)
if (!k) return
if (kinds.includes(k)) { Toast.show({ content: '该类型已存在' }); return }
setKinds((p) => [...p, k])
setNewKind('')
}
async function saveKinds() {
if (!kinds.length) { Toast.show({ content: '至少保留一个任务类型' }); return }
try {
await projectApi.update(project.id, { taskKinds: kinds })
Toast.show({ content: '已保存' })
setKindEdit(false)
onChanged?.()
} catch (e) {
Toast.show({ content: e.message })
}
}
// 项目图标: 图片裁为 96x96 cover JPEG data URL (保持现有尺寸/圆角由 CSS 承担)
function pickIcon() { iconInputRef.current?.click() }
function onIconFile(e) {
const file = e.target.files?.[0]
e.target.value = ''
if (!file || !file.type.startsWith('image/')) return
const url = URL.createObjectURL(file)
const img = new Image()
img.onload = async () => {
URL.revokeObjectURL(url)
const S = 96
const c = document.createElement('canvas')
c.width = S
c.height = S
const ctx = c.getContext('2d')
const r = Math.max(S / img.width, S / img.height)
const w = img.width * r
const h = img.height * r
ctx.drawImage(img, (S - w) / 2, (S - h) / 2, w, h)
const dataUrl = c.toDataURL('image/jpeg', 0.85)
try {
await projectApi.update(project.id, { icon: dataUrl })
Toast.show({ content: '图标已更新' })
onChanged?.()
} catch (err) {
Toast.show({ content: err.message || '图标保存失败' })
}
}
img.onerror = () => URL.revokeObjectURL(url)
img.src = url
}
async function clearIcon() {
try {
await projectApi.update(project.id, { icon: null })
Toast.show({ content: '已恢复默认' })
onChanged?.()
} catch (e) {
Toast.show({ content: e.message })
}
}
async function quit() { const ok = await Dialog.confirm({
content: '退出该项目?退出后将不再可见。', content: '退出该项目?退出后将不再可见。',
confirmText: '退出', confirmText: '退出',
}) })
@@ -164,6 +238,16 @@ export default function ProjectManage({ project, onClose, onChanged, onExited })
<span className="link">列设置 ›</span> <span className="link">列设置 ›</span>
</div> </div>
<div className="section-h" style={{ margin: '14px 14px 8px' }}>任务类型</div>
<div className="set-row" style={{ padding: '12px 14px' }} onClick={() => { setKinds(project.taskKinds || DEFAULT_KINDS); setNewKind(''); setKindEdit(true) }}>
<div className="kind-chip-row" style={{ flex: 1 }}>
{(project.taskKinds || DEFAULT_KINDS).map((k) => (
<span key={k} className="kind-tag" style={{ background: kindColor(k) }}>{k}</span>
))}
</div>
<span className="link">编辑 ›</span>
</div>
<div className="section-h" style={{ margin: '14px 14px 8px' }}>信息块({notes.length})</div> <div className="section-h" style={{ margin: '14px 14px 8px' }}>信息块({notes.length})</div>
{notes.map((n, i) => ( {notes.map((n, i) => (
<div <div
@@ -184,6 +268,25 @@ export default function ProjectManage({ project, onClose, onChanged, onExited })
</div> </div>
<div className="section-h" style={{ margin: '14px 14px 8px' }}>项目信息</div> <div className="section-h" style={{ margin: '14px 14px 8px' }}>项目信息</div>
<div className="set-row" style={{ padding: '12px 14px' }}>
<span className="pn" style={{ flex: 1, fontSize: 13.5 }}>项目图标</span>
<div
className="pavatar"
style={{
width: 34, height: 34, borderRadius: 9, fontSize: 14,
background: project.icon
? `url(${project.icon}) center/cover no-repeat`
: colorOfProject(project),
cursor: 'pointer',
}}
onClick={pickIcon}
>
{project.icon ? '' : (project.name || '?').slice(0, 1)}
</div>
{project.icon && <span className="link" style={{ marginLeft: 10 }} onClick={clearIcon}>恢复</span>}
<span className="link" style={{ marginLeft: 10 }} onClick={pickIcon}>更换</span>
<input ref={iconInputRef} type="file" accept="image/*" hidden onChange={onIconFile} />
</div>
<div className="set-row" style={{ padding: '12px 14px' }} onClick={() => { setName(project.name); setRenaming(true) }}> <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 className="pn" style={{ flex: 1, fontSize: 13.5 }}>项目名称</span>
<span style={{ fontSize: 12.5, color: '#999' }}>{project.name}</span> <span style={{ fontSize: 12.5, color: '#999' }}>{project.name}</span>
@@ -225,12 +328,60 @@ export default function ProjectManage({ project, onClose, onChanged, onExited })
onClose={() => setRenaming(false)} onClose={() => setRenaming(false)}
/> />
{/* 任务类型编辑: chips 删除 + 输入新增 */}
<Popup
visible={kindEdit}
onMaskClick={() => setKindEdit(false)}
bodyStyle={{
borderTopLeftRadius: 12,
borderTopRightRadius: 12,
padding: `16px 16px calc(env(safe-area-inset-bottom, 0px) + 20px + ${kb}px)`,
}}
>
<div className="sheet-title">任务类型</div>
<div className="sheet-sub">任务的分类标签,显示在任务卡片左下角</div>
<div className="kind-chip-row" style={{ marginTop: 12 }}>
{kinds.map((k) => (
<span key={k} className="kind-chip" style={{ background: kindColor(k) }}>
{k}
<span
className="x"
onClick={() => setKinds((p) => p.filter((x) => x !== k))}
>×</span>
</span>
))}
</div>
<div className="extra-row" style={{ display: 'flex', gap: 8, marginTop: 14 }}>
<input
className="inp"
style={{ flex: 1 }}
placeholder="新类型名称"
value={newKind}
maxLength={20}
onChange={(e) => setNewKind(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') addKind() }}
/>
<span className="mini-btn solid" style={{ padding: '0 16px', display: 'inline-flex', alignItems: 'center' }} onClick={addKind}>添加</span>
</div>
<div className="btn-primary" onClick={saveKinds}>保存</div>
<div className="btn-ghost" onClick={() => setKindEdit(false)}>取消</div>
</Popup>
{/* 信息块编辑 (T-PJ-13): 标题 + 长文本, 独立编辑 */} {/* 信息块编辑 (T-PJ-13): 标题 + 长文本, 独立编辑 */}
<Popup <Popup
visible={!!noteEdit} visible={!!noteEdit}
onMaskClick={() => setNoteEdit(null)} onMaskClick={() => setNoteEdit(null)}
position="bottom" position="bottom"
className="kb-safe" 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-title">{noteEdit?.index >= 0 ? '编辑信息块' : '添加信息块'}</div>
<div className="sheet-sub">用于记录 Readme 等项目相关长文本,每块独立编辑</div> <div className="sheet-sub">用于记录 Readme 等项目相关长文本,每块独立编辑</div>
@@ -249,7 +400,6 @@ export default function ProjectManage({ project, onClose, onChanged, onExited })
<textarea <textarea
className="inp" className="inp"
rows={10} rows={10}
onFocus={(e) => { setTimeout(() => e.target.scrollIntoView({ block: 'center', behavior: 'smooth' }), 300) }}
placeholder="支持多行长文本…" placeholder="支持多行长文本…"
value={noteEdit?.content || ''} value={noteEdit?.content || ''}
onChange={(e) => setNoteEdit((p) => ({ ...(p || {}), content: e.target.value }))} onChange={(e) => setNoteEdit((p) => ({ ...(p || {}), content: e.target.value }))}
+54 -26
View File
@@ -3,6 +3,7 @@ import { ActionSheet, Dialog, DotLoading, Toast } from 'antd-mobile'
import { projectApi, taskApi } from '../../api/task.js' import { projectApi, taskApi } from '../../api/task.js'
import { filterVisible, msToNextBoundary } from './date.js' import { filterVisible, msToNextBoundary } from './date.js'
import { getViewMode, setViewMode } from './prefs.js' import { getViewMode, setViewMode } from './prefs.js'
import { parseTimePrefix, splitTitle } from './quickAdd.js'
import ListView from './ListView.jsx' import ListView from './ListView.jsx'
import BoardView from './BoardView.jsx' import BoardView from './BoardView.jsx'
import TaskDetail from './TaskDetail.jsx' import TaskDetail from './TaskDetail.jsx'
@@ -102,6 +103,33 @@ export default function ProjectView({ projectId, onProjectChanged, onProjectExit
} catch { /* 已回滚并提示 */ } } catch { /* 已回滚并提示 */ }
} }
/** 组内拖动排序: 按分组顺序展平重写 sortOrder (只动顶层任务) */
const reorderTask = async (task, targetId, after) => {
const snapshot = tasks
const orderedCols = [...(columns || [])].sort((a, b) => a.sortOrder - b.sortOrder)
const groups = orderedCols.map((c) => (tasks || []).filter((t) => t.columnId === c.id)).filter((l) => l.length)
const g = groups.find((l) => l.some((t) => t.id === task.id))
if (!g) return
const rest = g.filter((t) => t.id !== task.id)
const ti = rest.findIndex((t) => t.id === targetId)
if (ti < 0) return
rest.splice(after ? ti + 1 : ti, 0, task)
const flat = groups.map((l) => (l === g ? rest : l)).flat()
const flatIds = new Set(flat.map((t) => t.id))
// 乐观更新必须重排数组本身 —— 列表分组渲染按数组顺序,
// 只改 sortOrder 字段界面不会动 (打卡侧即按此口径)
setTasks((prev) => [
...flat.map((t, i) => ({ ...t, sortOrder: i })),
...(prev || []).filter((t) => !flatIds.has(t.id)),
])
try {
await taskApi.reorder(projectId, flat.map((t) => t.id))
} catch (e) {
setTasks(snapshot)
Toast.show({ content: e.message || '排序保存失败' })
}
}
/** 改状态 (T-BD-14~16) */ /** 改状态 (T-BD-14~16) */
const changeStatus = async (task, status) => { const changeStatus = async (task, status) => {
try { try {
@@ -114,12 +142,21 @@ export default function ProjectView({ projectId, onProjectChanged, onProjectExit
} catch { /* ignore */ } } catch { /* ignore */ }
} }
/** 新建任务 */ /** 新建任务 (快速添加): 时间词前缀 → 时间标记; 超过 30 字在分隔符处截断,
async function createTask(title, parentId = null) { * 前段为标题, 余文并入描述 (T-TK-14/16) */
const t = String(title || '').trim() async function createTask(rawTitle, parentId = null) {
if (!t) return const raw = String(rawTitle || '').trim()
if (!raw) return
const { markDate, rest } = parseTimePrefix(raw)
const { title, desc } = splitTitle(rest)
if (!title) return
try { try {
await taskApi.create(projectId, { title: t, parentId }) await taskApi.create(projectId, {
title,
description: desc || undefined,
markDate: markDate || undefined,
parentId,
})
setAdding(false) setAdding(false)
load() load()
} catch (e) { } catch (e) {
@@ -127,24 +164,9 @@ export default function ProjectView({ projectId, onProjectChanged, onProjectExit
} }
} }
/** 长按任务 → 移动到列 (重点设计 §7.1: 列表页不做自由拖动, 菜单可靠) */ /** 任务 ⋯ 菜单: 编辑 / 拷贝到其他项目 / 删除 (点选后菜单收回) */
function openMoveMenu(task) {
ActionSheet.show({
actions: (columns || []).map((c) => ({
text: c.name + (c.id === task.columnId ? ' ✓' : ''),
key: String(c.id),
})),
cancelText: '取消',
onAction: (item) => {
const columnId = Number(item.key)
if (columnId !== task.columnId) moveToColumn(task, columnId)
},
})
}
/** 任务 ⋯ 菜单: 编辑 / 拷贝到其他项目 / 删除 */
function openTaskMenu(task) { function openTaskMenu(task) {
ActionSheet.show({ const handler = ActionSheet.show({
actions: [ actions: [
{ text: '编辑', key: 'edit' }, { text: '编辑', key: 'edit' },
{ text: '拷贝到其他项目', key: 'copy' }, { text: '拷贝到其他项目', key: 'copy' },
@@ -152,6 +174,7 @@ export default function ProjectView({ projectId, onProjectChanged, onProjectExit
], ],
cancelText: '取消', cancelText: '取消',
onAction: async (item) => { onAction: async (item) => {
handler.close?.()
if (item.key === 'edit') setOpenTask(task) if (item.key === 'edit') setOpenTask(task)
else if (item.key === 'copy') setCopyTask(task) else if (item.key === 'copy') setCopyTask(task)
else if (item.key === 'delete') { else if (item.key === 'delete') {
@@ -217,12 +240,15 @@ export default function ProjectView({ projectId, onProjectChanged, onProjectExit
{adding && ( {adding && (
<div className="quick-add"> <div className="quick-add">
<input <textarea
className="inp" className="inp"
autoFocus autoFocus
placeholder="输入任务标题,回车创建" rows={3}
maxLength={200}
placeholder={'输入任务标题,回车创建;开头可用 今天/明天/本周/本月 等时间词\n超过 30 字自动截断为标题,全文进描述'}
onBlur={(e) => { if (!e.currentTarget.value.trim()) setAdding(false) }}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === 'Enter') createTask(e.currentTarget.value) if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); createTask(e.currentTarget.value) }
if (e.key === 'Escape') setAdding(false) if (e.key === 'Escape') setAdding(false)
}} }}
/> />
@@ -236,7 +262,8 @@ export default function ProjectView({ projectId, onProjectChanged, onProjectExit
onToggle={toggleDone} onToggle={toggleDone}
onOpen={(t) => setOpenTask(t)} onOpen={(t) => setOpenTask(t)}
onMore={openTaskMenu} onMore={openTaskMenu}
onLongPress={openMoveMenu} onMoveColumn={moveToColumn}
onReorder={reorderTask}
/> />
) : ( ) : (
<BoardView <BoardView
@@ -258,6 +285,7 @@ export default function ProjectView({ projectId, onProjectChanged, onProjectExit
columns={columns} columns={columns}
members={project.members || []} members={project.members || []}
tasks={tasks || []} tasks={tasks || []}
taskKinds={project.taskKinds}
onClose={() => setOpenTask(null)} onClose={() => setOpenTask(null)}
onSaved={() => { setOpenTask(null); load() }} onSaved={() => { setOpenTask(null); load() }}
onChanged={load} onChanged={load}
+95 -11
View File
@@ -1,30 +1,38 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { ActionSheet, Popup, Toast } from 'antd-mobile' import { ActionSheet, Popup, Toast, DatePicker } from 'antd-mobile'
import { taskApi } from '../../api/task.js' import { taskApi } from '../../api/task.js'
import { STATUS_OPTIONS, STATUS_LABEL } from './date.js' import { STATUS_OPTIONS, STATUS_LABEL } from './date.js'
import { kindColor } from './kind.js'
import { parseTimePrefix, splitTitle } from './quickAdd.js'
import UserPicker from '../../components/task/UserPicker.jsx' import UserPicker from '../../components/task/UserPicker.jsx'
import StatusDot from '../../components/task/StatusDot.jsx' import StatusDot from '../../components/task/StatusDot.jsx'
import useKeyboardInset from '../../hooks/useKeyboardInset.js'
import useOverlayBack from '../../hooks/useOverlayBack.js' import useOverlayBack from '../../hooks/useOverlayBack.js'
// 任务详情 (T-TK-*, T-ST-03, T-AS-05) // 任务详情 (T-TK-*, T-ST-03, T-AS-05)
// - 状态: 三段式分段控件, 当前项高亮, 点按即切换 // - 状态: 三段式分段控件, 当前项高亮, 点按即切换
// - 看板列: ActionSheet 选择, 列/状态联动由服务端裁决 (T-BD-*) // - 看板列: ActionSheet 选择, 列/状态联动由服务端裁决 (T-BD-*)
// - 转为子任务 / 转为顶层任务 (T-ST-12): 从项目内顶层任务中选父任务 // - 转为子任务 / 转为顶层任务 (T-ST-12): 从项目内顶层任务中选父任务
export default function TaskDetail({ task, columns, members, tasks, onClose, onSaved, onChanged }) { export default function TaskDetail({ task, columns, members, tasks, taskKinds, onClose, onSaved, onChanged }) {
const [form, setForm] = useState(() => ({ const [form, setForm] = useState(() => ({
title: task.title || '', title: task.title || '',
description: task.description || '', description: task.description || '',
memberIds: (task.members || []).map((m) => m.userId), memberIds: (task.members || []).map((m) => m.userId),
assigneeId: task.assignee?.id ?? null, assigneeId: task.assignee?.id ?? null,
status: task.status, status: task.status,
kind: task.kind || null,
markDate: task.markDate ? task.markDate.slice(0, 10) : null,
tags: task.tags || [], tags: task.tags || [],
})) }))
const [subs, setSubs] = useState(task.children || []) const [subs, setSubs] = useState(task.children || [])
const [newSub, setNewSub] = useState('') const [newSub, setNewSub] = useState('')
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [picker, setPicker] = useState(null) // 'assignee' | 'members' const [picker, setPicker] = useState(null) // 'assignee' | 'members'
const [pickDate, setPickDate] = useState(false) // 时间标记日期选择
const [parentPick, setParentPick] = useState(false) // 转为子任务: 选择父任务 const [parentPick, setParentPick] = useState(false) // 转为子任务: 选择父任务
// 键盘弹出插入量: 抬升弹层内容, 输入框不被遮挡
const kb = useKeyboardInset()
// 返回键: 先关详情弹层, 不离开任务页 // 返回键: 先关详情弹层, 不离开任务页
useOverlayBack(true, onClose) useOverlayBack(true, onClose)
@@ -44,6 +52,8 @@ export default function TaskDetail({ task, columns, members, tasks, onClose, onS
memberIds: (task.members || []).map((m) => m.userId), memberIds: (task.members || []).map((m) => m.userId),
assigneeId: task.assignee?.id ?? null, assigneeId: task.assignee?.id ?? null,
status: task.status, status: task.status,
kind: task.kind || null,
markDate: task.markDate ? task.markDate.slice(0, 10) : null,
tags: task.tags || [], tags: task.tags || [],
}) })
setSubs(task.children || []) setSubs(task.children || [])
@@ -59,6 +69,8 @@ export default function TaskDetail({ task, columns, members, tasks, onClose, onS
assigneeId: form.assigneeId, assigneeId: form.assigneeId,
memberIds: form.memberIds, memberIds: form.memberIds,
status: form.status, status: form.status,
kind: form.kind,
markDate: form.markDate || null,
tags: form.tags, tags: form.tags,
}) })
Toast.show({ content: '已保存' }) Toast.show({ content: '已保存' })
@@ -104,10 +116,19 @@ export default function TaskDetail({ task, columns, members, tasks, onClose, onS
} }
async function addSub() { async function addSub() {
const t = newSub.trim() // 子任务快速添加同样支持时间词前缀 + 30 字分隔符截断 (T-TK-14/16)
if (!t) return const raw = newSub.trim()
if (!raw) return
const { markDate, rest } = parseTimePrefix(raw)
const { title, desc } = splitTitle(rest)
if (!title) return
try { try {
const r = await taskApi.create(task.projectId, { title: t, parentId: task.id }) const r = await taskApi.create(task.projectId, {
title,
description: desc || undefined,
markDate: markDate || undefined,
parentId: task.id,
})
setSubs((prev) => [...prev, r.item]) setSubs((prev) => [...prev, r.item])
setNewSub('') setNewSub('')
onChanged?.() onChanged?.()
@@ -127,12 +148,17 @@ export default function TaskDetail({ task, columns, members, tasks, onClose, onS
} }
} }
return ( return ( <Popup
<Popup
visible visible
onMaskClick={onClose} onMaskClick={onClose}
position="bottom" position="bottom"
className="kb-safe" 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-title">{task.title}</div>
<div className="sheet-sub"> <div className="sheet-sub">
@@ -144,7 +170,7 @@ export default function TaskDetail({ task, columns, members, tasks, onClose, onS
<input <input
className="inp" className="inp"
value={form.title} value={form.title}
maxLength={200} maxLength={30}
onChange={(e) => setForm((p) => ({ ...p, title: e.target.value }))} onChange={(e) => setForm((p) => ({ ...p, title: e.target.value }))}
/> />
</div> </div>
@@ -176,6 +202,61 @@ export default function TaskDetail({ task, columns, members, tasks, onClose, onS
</div> </div>
</div> </div>
{/* 时间标记: 可选日期, 卡片上以 MM-dd 显示 */}
<div className="field">
<label>时间标记</label>
<div className="pick-row">
<span className="pn">{form.markDate ? form.markDate.slice(5).replace('-', '/') : '未设置'}</span>
{form.markDate && (
<span className="link" onClick={() => setForm((p) => ({ ...p, markDate: null }))}>清除</span>
)}
<span className="link" onClick={() => setPickDate(true)}>选择 ›</span>
</div>
<DatePicker
visible={pickDate}
onClose={() => setPickDate(false)}
precision="day"
value={form.markDate ? new Date(form.markDate + 'T00:00:00') : new Date()}
onConfirm={(d) => {
const p2 = (n) => String(n).padStart(2, '0')
setForm((p) => ({ ...p, markDate: `${d.getFullYear()}-${p2(d.getMonth() + 1)}-${p2(d.getDate())}` }))
setPickDate(false)
}}
/>
</div>
{/* 类型: 项目任务类型标签, 可清空 */}
{(taskKinds || []).length > 0 && (
<div className="field">
<label>类型</label>
<div className="kind-chip-row" style={{ flexWrap: 'wrap' }}>
<span
className="kind-tag"
style={{
background: form.kind ? 'transparent' : 'var(--color-primary)',
color: form.kind ? '#999' : '#fff',
border: form.kind ? '1px dashed #ccc' : 'none',
cursor: 'pointer',
}}
onClick={() => setForm((p) => ({ ...p, kind: null }))}
>无</span>
{taskKinds.map((k) => (
<span
key={k}
className="kind-tag"
style={{
background: kindColor(k),
opacity: form.kind === k ? 1 : 0.45,
cursor: 'pointer',
outline: form.kind === k ? '2px solid rgba(22,119,255,.4)' : 'none',
}}
onClick={() => setForm((p) => ({ ...p, kind: p.kind === k ? null : k }))}
>{k}</span>
))}
</div>
</div>
)}
{/* 看板列: 点击换列, 状态随目标列联动 */} {/* 看板列: 点击换列, 状态随目标列联动 */}
<div className="field"> <div className="field">
<label>看板列</label> <label>看板列</label>
@@ -273,8 +354,11 @@ export default function TaskDetail({ task, columns, members, tasks, onClose, onS
<Popup <Popup
visible={parentPick} visible={parentPick}
onMaskClick={() => setParentPick(false)} onMaskClick={() => setParentPick(false)}
className="kb-safe" bodyStyle={{
bodyStyle={{ maxHeight: '70vh' }} 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-title">转为子任务</div>
<div className="sheet-sub">选择父任务(仅支持一级;已有子任务的任务不能作为父任务)</div> <div className="sheet-sub">选择父任务(仅支持一级;已有子任务的任务不能作为父任务)</div>
+16 -5
View File
@@ -126,8 +126,7 @@ export default function TaskPage() {
) )
} }
const COLORS = ['#1677ff', '#00b578', '#ff8f1f', '#a855f7', '#14b8a6', '#f43f9e'] import { colorOfProject } from './projectIcon.js'
const colorOf = (p) => COLORS[Math.abs(p.id) % COLORS.length]
const LONG_PRESS = 300 const LONG_PRESS = 300
const MOVE_TOLERANCE = 8 const MOVE_TOLERANCE = 8
@@ -286,8 +285,13 @@ function ProjectList({ projects, err, onRetry, onOpen, onChanged, createSignal,
onMouseDown={(ev) => beginPress(p, ev)} onMouseDown={(ev) => beginPress(p, ev)}
onClick={() => { if (!activeRef.current && !dragId) onOpen(p.id) }} onClick={() => { if (!activeRef.current && !dragId) onOpen(p.id) }}
> >
<div className="pico" style={{ background: colorOf(p) }}> <div
{(p.name || '?').slice(0, 1)} className="pico"
style={p.icon
? { background: `url(${p.icon}) center/cover no-repeat` }
: { background: colorOfProject(p) }}
>
{p.icon ? '' : (p.name || '?').slice(0, 1)}
</div> </div>
<div className="pmain"> <div className="pmain">
<div className="ptitle"> <div className="ptitle">
@@ -315,7 +319,14 @@ function ProjectList({ projects, err, onRetry, onOpen, onChanged, createSignal,
<div className="proj-group">已归档({archived.length})</div> <div className="proj-group">已归档({archived.length})</div>
{archived.map((p) => ( {archived.map((p) => (
<div className="proj-item" key={p.id} style={{ opacity: 0.65 }} onClick={() => onOpen(p.id)}> <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="pico"
style={p.icon
? { background: `url(${p.icon}) center/cover no-repeat` }
: { background: '#a8b0ba' }}
>
{p.icon ? '' : (p.name || '?').slice(0, 1)}
</div>
<div className="pmain"> <div className="pmain">
<div className="ptitle">{p.name}</div> <div className="ptitle">{p.name}</div>
<div className="psub">已归档 · {p.taskCount ?? 0} 个任务</div> <div className="psub">已归档 · {p.taskCount ?? 0} 个任务</div>
+12
View File
@@ -0,0 +1,12 @@
// 任务类型标签 (项目级自定义, 默认四类)
const KIND_COLORS = {
需求: '#1677ff',
Bug: '#ff3141',
bug: '#ff3141',
FEATURE: '#a855f7',
优化: '#ff8f1f',
}
export const DEFAULT_KINDS = ['需求', 'Bug', 'FEATURE', '优化']
export const kindColor = (k) => KIND_COLORS[k] || '#8a919c'
+4
View File
@@ -0,0 +1,4 @@
// 项目头像配色: 项目列表 / 最近项目 / 项目管理 三处共用, 保证观感一致
export const PROJECT_COLORS = ['#1677ff', '#00b578', '#ff8f1f', '#a855f7', '#14b8a6', '#f43f9e']
export const colorOfProject = (p) => PROJECT_COLORS[Math.abs(p.id) % PROJECT_COLORS.length]
+76
View File
@@ -0,0 +1,76 @@
// 快速添加的标题解析: 时间词前缀 + 30 字分隔符截断 (T-TK-14/16)
export const MAX_TITLE = 30
// 分隔符: 空格 / 中英文标点 / 斜杠等 —— 截断优先落在分隔符上, 不切词
const SEPARATOR = /[\s,,。;;、!!??::“”"'’‘()()\[\]【】\-_/\\|]/
/**
* 长文本拆分: ≤30 字原样; 超长时在 30 字内(且不少于 8 字)找最后一个分隔符截断,
* 前段为标题, **完整原文**作为描述 (整体进详情, 不丢内容)。
*/
export function splitTitle(raw) {
const text = String(raw || '').trim()
if (text.length <= MAX_TITLE) return { title: text, desc: '' }
let cut = -1
for (let i = MAX_TITLE; i >= 8; i--) {
if (SEPARATOR.test(text[i - 1])) { cut = i; break }
}
if (cut < 0) cut = MAX_TITLE
// 截断落在分隔符上: 分隔符留在标题末尾很难看, 去掉尾部连续分隔符/空白
// (\p{P} 覆盖中英文全部标点; 之前用 SEPARATOR.source 重组字符类有转义坑)
const title = text.slice(0, cut).replace(/[\s\p{P}]+$/u, '').trim()
return { title, desc: text }
}
// 时间词 → 日期 (返回 YYYY-MM-DD):
// 今天/明天/后天 = 当日 +0/+1/+2
// 本周 = 本周日, 下周 = 下周日 (周一定义为一周开始)
// 本月 = 本月最后一天, 下月 = 下月最后一天
const toISO = (d) => {
const p = (n) => String(n).padStart(2, '0')
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`
}
export function dateFromWord(word, now = new Date()) {
const day = (n) => { const d = new Date(now); d.setDate(d.getDate() + n); return toISO(d) }
const endOfWeek = (offsetWeeks) => {
const d = new Date(now)
const dow = (d.getDay() + 6) % 7 // 周一=0
d.setDate(d.getDate() + offsetWeeks * 7 + (6 - dow))
return toISO(d)
}
const endOfMonth = (offsetMonths) => {
const d = new Date(now.getFullYear(), now.getMonth() + offsetMonths + 1, 0)
return toISO(d)
}
switch (word) {
case '今天': return day(0)
case '明天': return day(1)
case '后天': return day(2)
case '本周': return endOfWeek(0)
case '下周': return endOfWeek(1)
case '本月': return endOfMonth(0)
case '下月': return endOfMonth(1)
default: return null
}
}
// 匹配最长的时间词前缀 (后面跟分隔符或直接结束)
const TIME_WORDS = ['本周', '下周', '本月', '下月', '今天', '明天', '后天']
/**
* 解析时间词前缀: "明天 交周报" → { markDate: '2026-10-26', rest: '交周报' }
* 无匹配时 rest 为原文, markDate 为 null。
*/
export function parseTimePrefix(raw) {
const text = String(raw || '').trim()
for (const w of TIME_WORDS) {
if (text.startsWith(w)) {
const after = text.slice(w.length)
if (after === '' || SEPARATOR.test(after[0])) {
return { markDate: dateFromWord(w), rest: after.trim() }
}
}
}
return { markDate: null, rest: text }
}
+174 -41
View File
@@ -23,6 +23,13 @@
* { box-sizing: border-box; margin: 0; padding: 0; } * { box-sizing: border-box; margin: 0; padding: 0; }
/* 全局禁用非编辑类文字的选中/长按菜单 (输入框除外);
各页面原有的局部规则保留但不再必要 */
* { -webkit-user-select: none; user-select: none; -webkit-touch-callout: none; }
input, textarea, [contenteditable="true"], [contenteditable=""] {
-webkit-user-select: text; user-select: text; -webkit-touch-callout: default;
}
html, body, #root { html, body, #root {
height: 100%; height: 100%;
background: var(--color-bg); background: var(--color-bg);
@@ -72,6 +79,11 @@ input, textarea, select { font: inherit; }
-webkit-overflow-scrolling: touch; -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) */ /* 页面切换动画: pathname 变化时整页淡入 + 轻微上移 (由 MobileShell 控制 class) */
.page-transition.active { .page-transition.active {
animation: page-in 0.28s ease; animation: page-in 0.28s ease;
@@ -658,6 +670,77 @@ body.me-safe-top .app-safe-top { height: 0; }
.habit-link { font-size: 12px; color: var(--color-primary); cursor: pointer; } .habit-link { font-size: 12px; color: var(--color-primary); cursor: pointer; }
.habit-link.danger { color: var(--color-danger); } .habit-link.danger { color: var(--color-danger); }
/* 打卡项分组 + 长按拖动排序 (组内) */
.habit-view { -webkit-user-select: none; user-select: none; -webkit-touch-callout: none; }
.habit-view input, .habit-view textarea { -webkit-user-select: text; user-select: text; }
.habit-group-title {
display: flex; align-items: center; gap: 7px; padding: 10px 2px 4px;
font-size: 12px; color: var(--color-muted); font-weight: 600;
}
.habit-group-title::before { content: ""; width: 3px; height: 12px; border-radius: 2px; background: var(--color-primary); }
.habit-group-title .cnt { color: #b8bcc4; font-weight: 400; }
.habit-card { touch-action: pan-y; }
.habit-card.dragging { opacity: 0.55; outline: 2px dashed var(--color-primary); outline-offset: 1px; }
.habit-card.dragover { outline: 2px solid var(--color-primary); outline-offset: 1px; }
.habit-card.dragover.after { border-bottom: 3px solid var(--color-primary); }
.habit-card.dragover.before { border-top: 3px solid var(--color-primary); }
/* 拖动把手 (⠿): touch-action:none —— 浏览器完全不接管该触摸,
指针流不会被滚动抢占取消, 是 Android WebView 上唯一可靠的拖动入口 */
.grip {
touch-action: none; -webkit-user-select: none; user-select: none;
color: #c2c7cf; font-size: 10px; line-height: 1;
padding: 10px 1px; margin: -6px -2px; min-width: 8px; min-height: 26px;
display: inline-flex; align-items: center; justify-content: center;
cursor: grab; flex-shrink: 0; align-self: center;
}
.grip:active { color: var(--color-primary); }
.task-right .grip { margin-right: 2px; }
/* MergedView 打卡卡拖动排序 */
.mv-view { -webkit-user-select: none; user-select: none; -webkit-touch-callout: none; }
.mv-view input, .mv-view textarea { -webkit-user-select: text; user-select: text; }
.mv-card { touch-action: pan-y; }
.mv-card.dragging { opacity: .3; outline: 2px dashed var(--color-primary); outline-offset: 1px; }
.mv-card.dragover { outline: 2px solid var(--color-primary); outline-offset: 1px; }
.mv-card.dragover.after { border-bottom: 3px solid var(--color-primary); }
.mv-card.dragover.before { border-top: 3px solid var(--color-primary); }
/* 任务类型小标签: 列表行左下 / 看板卡左下 */
.date-tag {
display: inline-flex; align-items: center;
font-size: 10px; line-height: 1; font-weight: 600;
color: #ad6800; background: #fff7e6; border-radius: 4px; padding: 3px 5px;
flex-shrink: 0; white-space: nowrap; font-variant-numeric: tabular-nums;
}
.kind-tag {
display: inline-flex; align-items: center;
font-size: 10px; line-height: 1; font-weight: 600; color: #fff;
border-radius: 4px; padding: 3px 5px; flex-shrink: 0; white-space: nowrap;
}
/* 项目管理: 任务类型编辑 chips */
.kind-chip-row { display: flex; flex-wrap: wrap; gap: 8px; }
.kind-chip {
display: inline-flex; align-items: center; gap: 5px;
font-size: 12.5px; color: #fff; border-radius: 999px; padding: 5px 6px 5px 11px;
}
.kind-chip .x { font-size: 13px; color: rgba(255,255,255,.75); cursor: pointer; padding: 0 4px; }
/* 拖动跟手浮层: 克隆原卡片淡化后挂在 body 顶层, 跟随指针 */
.drag-ghost {
position: fixed; left: 0; top: 0; z-index: 1000;
pointer-events: none; will-change: transform;
background: transparent; padding: 0; margin: 0; max-width: none; border-radius: 0;
-webkit-user-select: none; user-select: none; -webkit-touch-callout: none;
-webkit-tap-highlight-color: transparent;
}
.drag-ghost * { -webkit-tap-highlight-color: transparent; }
.drag-ghost > * {
margin: 0 !important; pointer-events: none;
opacity: .82; border-radius: 9px;
box-shadow: 0 12px 28px rgba(15,23,42,.28);
}
/* ----- 日历视图 ----- */ /* ----- 日历视图 ----- */
.calendar-view { padding: 0 12px 20px; } .calendar-view { padding: 0 12px 20px; }
.cal-month-bar { display: flex; align-items: center; gap: 10px; padding: 8px 2px; } .cal-month-bar { display: flex; align-items: center; gap: 10px; padding: 8px 2px; }
@@ -902,8 +985,17 @@ body.me-safe-top .app-safe-top { height: 0; }
width: 34px; height: 34px; border-radius: 9px; display: flex; align-items: center; 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; justify-content: center; color: #fff; font-size: 14px; font-weight: 700; flex-shrink: 0;
} }
/* 项目头像基类 (不依赖 .proj-item 作用域): 项目管理编辑页预览等裸用场景 */
.pavatar {
display: flex; align-items: center; justify-content: center;
color: #fff; font-weight: 700; flex-shrink: 0;
background-size: cover; background-position: center; background-repeat: no-repeat;
}
.proj-item .pmain { flex: 1; min-width: 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 .ptitle {
font-size: 14.5px; font-weight: 600; display: flex; align-items: center; gap: 6px;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.proj-item .psub { font-size: 11.5px; color: var(--color-muted); margin-top: 2px; } .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; } .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; } .badge { font-size: 10px; color: #ad6800; background: #fff7e6; border-radius: 4px; padding: 0 5px; font-weight: 400; }
@@ -942,21 +1034,30 @@ body.me-safe-top .app-safe-top { height: 0; }
.task-main { flex: 1; min-width: 0; } .task-main { flex: 1; min-width: 0; }
.task-title { font-size: 14px; display: flex; align-items: center; gap: 6px; word-break: break-word; } .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-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 {
.task-meta .col { color: var(--color-primary); background: #f0f5ff; border-radius: 4px; padding: 0 5px; } display: flex; align-items: center; gap: 7px; margin-top: 4px; font-size: 11px;
color: #999; flex-wrap: wrap; min-height: 17px;
}
.task-meta .assignee { font-size: 12px; font-weight: 600; color: var(--color-text); } .task-meta .assignee { font-size: 12px; font-weight: 600; color: var(--color-text); }
.task-meta .assignee.none { color: #b8bcc4; font-weight: 400; } .task-meta .assignee.none { color: #b8bcc4; font-weight: 400; }
.task-meta .members { font-size: 10.5px; color: #a7adb6; } .task-meta .members { font-size: 10.5px; color: #a7adb6; }
.task-right { display: flex; align-items: center; flex-shrink: 0; } .task-right {
display: flex; flex-direction: column; align-items: flex-end; justify-content: space-between;
align-self: stretch; flex-shrink: 0;
}
.task-right .assignee { font-size: 12px; font-weight: 600; color: var(--color-text); }
.task-right .assignee.none { color: #b8bcc4; font-weight: 400; }
.more { color: #c2c7cf; font-size: 16px; line-height: 1; padding: 0 2px; cursor: pointer; } .more { color: #c2c7cf; font-size: 16px; line-height: 1; padding: 0 2px; cursor: pointer; }
.sub-row { .sub-row {
display: flex; align-items: center; gap: 8px; background: #fff; border-radius: 8px; display: flex; align-items: flex-start; 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); padding: 8px 12px 8px 30px; margin: -2px 12px 7px 26px; box-shadow: 0 1px 2px rgba(0,0,0,.03);
position: relative; cursor: pointer; position: relative; cursor: pointer;
} }
.sub-row::before { content: ""; position: absolute; left: 14px; top: 0; bottom: 0; width: 1px; background: #e9ecf0; } .sub-row::before { content: ""; position: absolute; left: 14px; top: 0; bottom: 0; width: 1px; background: #e9ecf0; }
.sub-row .box { width: 16px; height: 16px; } .sub-row .box { width: 16px; height: 16px; margin-top: 2px; }
.sub-title { flex: 1; font-size: 13px; color: #444; } .sub-main { flex: 1; min-width: 0; }
.sub-title { display: block; font-size: 13px; color: #444; }
.sub-row .task-meta { font-size: 10.5px; margin-top: 2px; gap: 5px; }
.sub-row.done .sub-title { color: #a8a8a8; text-decoration: line-through; } .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; } .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; } .sub-add-hint { font-size: 11px; color: #b8bcc4; padding: 0 0 8px 44px; cursor: pointer; }
@@ -976,25 +1077,45 @@ body.me-safe-top .app-safe-top { height: 0; }
display: flex; gap: 10px; padding: 0 12px 8px; overflow-x: auto; overflow-y: hidden; display: flex; gap: 10px; padding: 0 12px 8px; overflow-x: auto; overflow-y: hidden;
flex: 1 1 auto; min-height: 0; align-items: stretch; flex: 1 1 auto; min-height: 0; align-items: stretch;
} }
.bcol { flex: 0 0 186px; display: flex; flex-direction: column; min-height: 0; } .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-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; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.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-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 { 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-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-body {
flex: 1 1 auto; min-height: 0; height: 100%; overflow-y: auto;
display: flex; flex-direction: column; gap: 7px; padding-bottom: 8px;
-webkit-overflow-scrolling: touch; overscroll-behavior: contain;
}
/* 列体包裹层: 承载右缘滚动条 (滚动时出现, 停止后淡出) */
.bcol-main { flex: 1 1 auto; min-height: 0; position: relative; display: flex; }
.bcol-bar {
position: absolute; right: 1px; width: 3px; border-radius: 2px;
background: rgba(15, 23, 42, .3); opacity: 0; transition: opacity .25s ease;
pointer-events: none; z-index: 3;
}
.bcol-bar.on { opacity: 1; }
.bcol.dragover .bcol-body { background: #eaf2ff; border-radius: 8px; outline: 1.5px dashed var(--color-primary); } .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; } .bcol-empty { font-size: 11px; color: #c0c4cc; text-align: center; padding: 10px 0; }
.bcard { .bcard {
background: #fff; border-radius: 8px; padding: 9px 10px; background: #fff; border-radius: 8px; padding: 9px 10px;
box-shadow: 0 1px 2px rgba(0,0,0,.05); cursor: pointer; flex-shrink: 0; box-shadow: 0 1px 2px rgba(0,0,0,.05); cursor: pointer; flex-shrink: 0;
display: flex; gap: 7px; align-items: flex-start;
} }
.bcard.dragging { opacity: .55; transform: scale(.97) rotate(-1deg); box-shadow: 0 8px 18px rgba(15,23,42,.16); } /* 左侧竖排操作列: 状态点在上, ⋯ 沉到左下角; 负外距悬进卡片内边距, 少占横向空间 */
.bcard-left {
display: flex; flex-direction: column; align-items: center; flex-shrink: 0;
align-self: stretch; justify-content: space-between; padding: 1px 0 2px;
margin: 0 -5px; width: 14px;
}
.bcard-left .bstat { padding: 0; }
.bcard-left .bmore-btn { font-size: 12px; padding: 0; line-height: 1.1; }
.bcard-body { flex: 1; min-width: 0; }
.bcard.hl { outline: 2px solid var(--color-primary); outline-offset: 1px; } .bcard.hl { outline: 2px solid var(--color-primary); outline-offset: 1px; }
.brow1 { display: flex; align-items: flex-start; gap: 6px; } .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; } .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; } .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; } .bmeta { display: flex; align-items: center; justify-content: flex-end; gap: 6px; margin-top: 6px; font-size: 10.5px; }
.bassignee { font-size: 11px; font-weight: 600; color: #4a5160; } .bassignee { font-size: 11px; font-weight: 600; color: #4a5160; }
.bassignee.none { color: #b8bcc4; font-weight: 400; } .bassignee.none { color: #b8bcc4; font-weight: 400; }
.bmembers { font-size: 10px; color: #a7adb6; } .bmembers { font-size: 10px; color: #a7adb6; }
@@ -1012,18 +1133,16 @@ body.me-safe-top .app-safe-top { height: 0; }
background: rgba(255,255,255,.6); border: 1px dashed #d5dae1; border-radius: 8px; 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; 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), 否则与系统工具栏重合 */ /* 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 { 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 { 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 .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 .title {
font-size: 16px; font-weight: 700;
min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.cs-page .navbar-sub .right { margin-left: auto; display: flex; gap: 6px; } .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; } .cs-body { flex: 1 1 auto; min-height: 0; overflow-y: auto; padding-bottom: 20px; }
.set-row { .set-row {
@@ -1045,8 +1164,14 @@ body.me-safe-top .app-safe-top { height: 0; }
.cs-count { font-size: 11px; color: #999; margin-left: 8px; } .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-title {
.sheet-sub { font-size: 11.5px; color: #999; margin-bottom: 14px; } font-size: 16px; font-weight: 700; margin-bottom: 4px; word-break: break-word;
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
}
.sheet-sub {
font-size: 11.5px; color: #999; margin-bottom: 14px;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.field { margin-bottom: 13px; } .field { margin-bottom: 13px; }
.field label { display: block; font-size: 12px; color: var(--color-muted); margin-bottom: 6px; } .field label { display: block; font-size: 12px; color: var(--color-muted); margin-bottom: 6px; }
.inp { .inp {
@@ -1134,7 +1259,7 @@ textarea.inp { resize: vertical; }
.pv-embed .task-content { flex: 1 1 auto; } .pv-embed .task-content { flex: 1 1 auto; }
/* ---- 项目列表拖动排序 (bug#3) ---- */ /* ---- 项目列表拖动排序 (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; } .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) ---- */ /* ---- 项目信息块 (T-PJ-13) ---- */
.note-row { cursor: pointer; } .note-row { cursor: pointer; }
@@ -1142,7 +1267,7 @@ textarea.inp { resize: vertical; }
.note-preview { .note-preview {
font-size: 11.5px; color: #999; margin-top: 3px; overflow: hidden; font-size: 11.5px; color: #999; margin-top: 3px; overflow: hidden;
text-overflow: ellipsis; white-space: nowrap; text-overflow: ellipsis; white-space: nowrap;
} }
/* ---- 通用分段控件 (状态选择等): 当前项高亮 ---- /* ---- 通用分段控件 (状态选择等): 当前项高亮 ----
此前只有 .pv-head .seg 有定义, 详情弹层里裸用 .seg 会堆成三行 */ 此前只有 .pv-head .seg 有定义, 详情弹层里裸用 .seg 会堆成三行 */
@@ -1151,25 +1276,33 @@ textarea.inp { resize: vertical; }
.seg div.on { background: #fff; color: var(--color-text); font-weight: 600; box-shadow: 0 1px 2px rgba(0,0,0,.06); } .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; } .bmore-btn { color: #c2c7cf; font-size: 14px; line-height: 1; padding: 2px 4px; cursor: pointer; }
.bcard-left .bmore-btn { padding: 0 4px; }
/* ---- ActionSheet 标题 (任务 ⋯ 菜单) ---- */ /* ---- ActionSheet 标题 (任务 ⋯ 菜单) ---- */
.as-extra { padding: 4px 0 8px; } .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; } .as-title { font-size: 13px; font-weight: 600; color: var(--color-text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* ---- 键盘避让弹层 (重点设计 §7.2) ---- /* ---- 拖动手势 (重点设计 §7.1) ----
底部弹层统一挂 .kb-safe: 底部留白 = 安全区 + 20px + 键盘插入量(--kb-inset, 看板卡片 / 列表行: 纵向滚动归浏览器(pan-y), 水平位移留给拖动换列。
由 App 级 useKeyboardInsetWatcher 写入)。键盘弹出时内容抬到键盘上方, 任务区整体禁用长按文字选择与系统菜单, 避免长按拖动变成选中文字 (输入框除外)。 */
弹层自身 max-height 用 dvh 随视口收缩并内部滚动。 */ .task-content {
.kb-safe { -webkit-user-select: none; user-select: none;
border-top-left-radius: 12px; -webkit-touch-callout: none;
border-top-right-radius: 12px; }
max-height: min(86dvh, calc(100dvh - 120px)); .task-content input, .task-content textarea { -webkit-user-select: text; user-select: text; }
overflow-y: auto; .bcard, .task-row { touch-action: pan-y; }
padding: 16px 16px calc(env(safe-area-inset-bottom, 0px) + 20px + var(--kb-inset, 0px)); .bcard.dragging {
} opacity: .35; outline: 2px dashed var(--color-primary); outline-offset: 1px;
}
/* ---- 拖动方案 (重点设计 §7.1) ---- .task-row.dragging {
卡片允许纵向滚动(PAN-Y), 水平位移留给拖动; 激活后原位抬起 */ opacity: .35; outline: 2px dashed var(--color-primary); outline-offset: 1px;
.bcard { touch-action: pan-y; } }
.bcard.dragging { opacity: .5; transform: scale(.97); box-shadow: 0 10px 22px rgba(15,23,42,.22); z-index: 6; } .task-row.dragover { outline: 2px solid var(--color-primary); outline-offset: 1px; }
.task-row.dragover.after { border-bottom: 3px solid var(--color-primary); }
.task-row.dragover.before { border-top: 3px solid var(--color-primary); }
.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; }