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
+12 -4
View File
@@ -7,6 +7,11 @@ on:
branches: branches:
- 'android-*' - 'android-*'
# 同分支连续 push 时取消旧的构建, 省资源也避免上传过时 APK
concurrency:
group: android-${{ github.ref_name }}
cancel-in-progress: true
jobs: jobs:
build-android: build-android:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -55,7 +60,7 @@ jobs:
with: with:
path: | path: |
~/.cargo ~/.cargo
~/.rustup ~/.rustup/toolchains
# 工具链升级时 bump 版本号使缓存失效重装 (含 cargo-ndk) # 工具链升级时 bump 版本号使缓存失效重装 (含 cargo-ndk)
key: rust-android-v1-${{ runner.os }} key: rust-android-v1-${{ runner.os }}
@@ -63,7 +68,9 @@ jobs:
uses: https://gitea.com/actions/cache@v4 uses: https://gitea.com/actions/cache@v4
with: with:
path: src-tauri/target path: src-tauri/target
key: tauri-target-android-${{ runner.os }} # 依赖变化 → 新 key; 命不中时回退最近一次的 target 目录做增量编译
key: tauri-target-android-${{ hashFiles('src-tauri/Cargo.lock') }}
restore-keys: tauri-target-android-
# ---------- 安装工具链 (仅缓存未命中时) ---------- # ---------- 安装工具链 (仅缓存未命中时) ----------
- name: Install Rust + Android targets - name: Install Rust + Android targets
@@ -72,7 +79,8 @@ jobs:
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable
echo "$HOME/.cargo/bin" >> $GITHUB_PATH echo "$HOME/.cargo/bin" >> $GITHUB_PATH
. "$HOME/.cargo/env" . "$HOME/.cargo/env"
rustup target add aarch64-linux-android armv7-linux-androideabi x86_64-linux-android i686-linux-android # 只构建 aarch64 (现代 Android 手机); 需要模拟器/x86 设备时再加回其他 target
rustup target add aarch64-linux-android
cargo install cargo-ndk --locked cargo install cargo-ndk --locked
- name: Install Android SDK & NDK - name: Install Android SDK & NDK
@@ -130,7 +138,7 @@ jobs:
export CARGO_HOME="$HOME/.cargo" export CARGO_HOME="$HOME/.cargo"
. "$HOME/.cargo/env" . "$HOME/.cargo/env"
cd src-tauri cd src-tauri
npx tauri android build --apk npx tauri android build --apk --target aarch64
# 断言签名配置仍在且 release 块实际引用了它 (防止 tauri 重新生成 gen/android 或正则未命中) # 断言签名配置仍在且 release 块实际引用了它 (防止 tauri 重新生成 gen/android 或正则未命中)
if ! grep -q 'create("release")' gen/android/app/build.gradle.kts || ! grep -q 'signingConfig = signingConfigs.getByName("release")' gen/android/app/build.gradle.kts; then if ! grep -q 'create("release")' gen/android/app/build.gradle.kts || ! grep -q 'signingConfig = signingConfigs.getByName("release")' gen/android/app/build.gradle.kts; then
echo "ERROR: 构建后签名配置丢失/未生效, APK 未签名" >&2 echo "ERROR: 构建后签名配置丢失/未生效, APK 未签名" >&2
+42 -7
View File
@@ -18,6 +18,11 @@ on:
branches: branches:
- master - master
# 连续 push master 时取消还在跑的旧部署, 避免排队执行过时版本的完整部署
concurrency:
group: deploy-master
cancel-in-progress: true
jobs: jobs:
build-image: build-image:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -38,6 +43,7 @@ jobs:
uses: https://gitea.com/actions/setup-node@v4 uses: https://gitea.com/actions/setup-node@v4
with: with:
node-version: 22 node-version: 22
cache: 'npm'
- name: Check version consistency & branch - name: Check version consistency & branch
# 1) package.json / tauri.conf.json / Cargo.toml / Cargo.lock 四处版本必须完全相等且不带 -ota # 1) package.json / tauri.conf.json / Cargo.toml / Cargo.lock 四处版本必须完全相等且不带 -ota
@@ -53,21 +59,43 @@ jobs:
run: npm ci --no-audit --no-fund run: npm ci --no-audit --no-fund
- name: Build frontend - name: Build frontend
# dist/ 供 OTA 打包用 (Dockerfile 内也会构建一份) # 全流程唯一一次前端构建: dist/ 既供 OTA 打包, 也通过 build context 进入镜像
# (docker build 传 PREBUILT_DIST=true 跳过镜像内的重复构建)
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 二进制,
# 逐个试运行挑出本机可执行的, 都不行再回退 apt
run: |
set -euo pipefail
mkdir -p /tmp/ms
curl -sL https://github.com/jedisct1/minisign/releases/download/0.12/minisign-0.12-linux.tar.gz | tar xz -C /tmp/ms
BIN=""
for c in $(find /tmp/ms -type f -name minisign); do
if "$c" -v >/dev/null 2>&1; then BIN="$c"; break; fi
done
if [ -n "$BIN" ]; then
sudo install -m755 "$BIN" /usr/local/bin/minisign
else
echo "未找到可执行的静态二进制, 回退 apt 安装"
sudo apt-get update && sudo apt-get install -y minisign
fi
minisign -v
- name: Build & sign OTA package - name: Build & sign OTA package
# 每次主分支提交生成 OTA 升级包, sequence 默认时间戳 (单调递增) # 每次主分支提交生成 OTA 升级包, sequence 默认时间戳 (单调递增)
# 发布说明 = 触发构建的 commit message (标题+正文; 打包脚本内安全截断 400 字符),
# commit sha 通过 --commit 单独写入 manifest, 前端分行展示
# 产物 ota-package/ (tar.gz + latest.json + .minisig) 进入 docker build context, 由 Dockerfile COPY 进镜像 # 产物 ota-package/ (tar.gz + latest.json + .minisig) 进入 docker build context, 由 Dockerfile COPY 进镜像
env: env:
MINISIGN_PRIVATE_KEY: ${{ secrets.MINISIGN_PRIVATE_KEY }} MINISIGN_PRIVATE_KEY: ${{ secrets.MINISIGN_PRIVATE_KEY }}
OTA_NOTES: ${{ github.sha }}
run: | run: |
set -euo pipefail set -euo pipefail
node scripts/build-ota.mjs --out ota-package --notes ${OTA_NOTES} --min-binary ${OTA_MIN_BINARY} # 不用 head -c 截断: 那会把多字节汉字切成非法 UTF-8, latest.json 解析失败
MSG="$(git log -1 --pretty=format:%B | sed -e '/^[[:space:]]*$/d')"
echo "notes: ${MSG}"
node scripts/build-ota.mjs --out ota-package --notes "${MSG}" --commit "${GITHUB_SHA}" --min-binary ${OTA_MIN_BINARY}
echo "===== latest.json =====" echo "===== latest.json ====="
cat ota-package/latest.json cat ota-package/latest.json
echo "===== bundle sha256 =====" echo "===== bundle sha256 ====="
@@ -75,7 +103,11 @@ jobs:
- name: Build Docker image - name: Build Docker image
# DATABASE_URL 仅在 prisma generate 阶段需要, 可用任何合法占位串 # DATABASE_URL 仅在 prisma generate 阶段需要, 可用任何合法占位串
run: docker build -t iboard:latest --build-arg DATABASE_URL=mysql://build:build@127.0.0.1:3306/build . # PREBUILT_DIST=true: 复用上一步构建好的 dist/, 镜像内跳过前端构建
# DOCKER_BUILDKIT=1: Dockerfile 用了 cache mount 语法
run: docker build -t iboard:latest --build-arg DATABASE_URL=mysql://build:build@127.0.0.1:3306/build --build-arg PREBUILT_DIST=true .
env:
DOCKER_BUILDKIT: '1'
- name: List images - name: List images
run: docker images run: docker images
@@ -95,8 +127,11 @@ jobs:
- name: Remove old container - name: Remove old container
run: docker rm -vf iboard || true run: docker rm -vf iboard || true
- name: Recycle container - name: Prune dangling images
run: docker system prune --volumes -f # 只清理本项目的悬空镜像 (旧 iboard:latest 被同名新镜像顶掉后变 dangling),
# 不再用 `docker system prune --volumes` —— 那会清光宿主机 build cache,
# 导致每次部署都全量重装 npm 依赖、重跑构建
run: docker image prune -f
- name: Run container - name: Run container
# DATABASE_URL 通过 gitea Actions secret 注入, 不进镜像层 # DATABASE_URL 通过 gitea Actions secret 注入, 不进镜像层
+28 -6
View File
@@ -46,21 +46,43 @@ jobs:
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 二进制,
# 逐个试运行挑出本机可执行的, 都不行再回退 apt
run: |
set -euo pipefail
mkdir -p /tmp/ms
curl -sL https://github.com/jedisct1/minisign/releases/download/0.12/minisign-0.12-linux.tar.gz | tar xz -C /tmp/ms
BIN=""
for c in $(find /tmp/ms -type f -name minisign); do
if "$c" -v >/dev/null 2>&1; then BIN="$c"; break; fi
done
if [ -n "$BIN" ]; then
sudo install -m755 "$BIN" /usr/local/bin/minisign
else
echo "未找到可执行的静态二进制, 回退 apt 安装"
sudo apt-get update && sudo apt-get install -y minisign
fi
minisign -v
- name: Build & sign OTA package - name: Build & sign OTA package
# 发布说明默认 = 检出 commit 的 message (标题+正文), commit sha 单独写入;
# 手动填写的 notes 优先
env: env:
MINISIGN_PRIVATE_KEY: ${{ secrets.MINISIGN_PRIVATE_KEY }} MINISIGN_PRIVATE_KEY: ${{ secrets.MINISIGN_PRIVATE_KEY }}
OTA_NOTES: ${{ inputs.notes }}
run: | run: |
set -euo pipefail set -euo pipefail
OUT="ota-package" OUT="ota-package"
ARGS="--out ${OUT}" ARGS="--out ${OUT} --commit ${GITHUB_SHA}"
# sequence 留空 → 脚本默认用时间戳; min_binary 默认 0.1.0 (与前端版本解耦)
if [ -n "${{ inputs.sequence }}" ]; then ARGS="${ARGS} --sequence ${{ inputs.sequence }}"; fi if [ -n "${{ inputs.sequence }}" ]; then ARGS="${ARGS} --sequence ${{ inputs.sequence }}"; fi
if [ -n "${OTA_NOTES}" ]; then ARGS="${ARGS} --notes ${OTA_NOTES}"; fi if [ -n "${{ inputs.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 if [ -n "${{ inputs.min_binary }}" ]; then ARGS="${ARGS} --min-binary ${{ inputs.min_binary }}"; fi
node scripts/build-ota.mjs ${ARGS} eval "node scripts/build-ota.mjs ${ARGS}"
echo "===== latest.json =====" echo "===== latest.json ====="
cat ${OUT}/latest.json cat ${OUT}/latest.json
echo "===== bundle sha256 =====" echo "===== bundle sha256 ====="
+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 | 要点 |
| --- | --- | --- | --- |
| — | — | — | 暂无 |
+18 -6
View File
@@ -1,8 +1,11 @@
# syntax=docker/dockerfile:1
# =============================================================== # ===============================================================
# iBoard 一体化镜像: 静态前端 (dist/) + Express API (server/) 同进程 # iBoard 一体化镜像: 静态前端 (dist/) + Express API (server/) 同进程
# 构建: docker build --build-arg DATABASE_URL=... -t iboard:latest . # 构建: docker build --build-arg DATABASE_URL=... -t iboard:latest .
# 可选 --build-arg VITE_API_BASE=https://api.example.com 让前端走绝对地址 # 可选 --build-arg VITE_API_BASE=https://api.example.com 让前端走绝对地址
# CI 传入 --build-arg PREBUILT_DIST=true 复用 CI 已构建的 dist/ (跳过镜像内前端构建)
# 运行: docker run -d --name iboard -p 3001:3001 -e DATABASE_URL=... iboard:latest # 运行: docker run -d --name iboard -p 3001:3001 -e DATABASE_URL=... iboard:latest
# 需要 BuildKit (cache mount 语法; Docker 23+ 默认开启)
# =============================================================== # ===============================================================
FROM node:22-alpine AS builder FROM node:22-alpine AS builder
@@ -17,18 +20,27 @@ COPY prisma ./prisma/
COPY prisma.config.ts ./ COPY prisma.config.ts ./
COPY generated ./generated/ COPY generated ./generated/
# 装全量依赖 (dev 也装, 后面 build 要用) # 装全量依赖 (dev 也装, 后面 prisma generate / 本地构建前端要用)
RUN npm install # cache mount 复用 npm 下载缓存, 不进镜像层
RUN --mount=type=cache,target=/root/.npm npm ci
# 拷贝源码 # 拷贝源码
COPY . . COPY . .
# 构建前端 (输出到 dist/) 与 Prisma 客户端 # 构建 Prisma 客户端
RUN npx prisma generate RUN --mount=type=cache,target=/root/.npm npx prisma generate
# 构建前端 (输出到 dist/)
# PREBUILT_DIST=true: CI 已在镜像外构建过 dist/ (供 OTA 打包), 直接复用, 跳过镜像内构建
ARG PREBUILT_DIST=false
# build 时把 VITE_API_BASE 写进 bundle, 默认空 = 走相对路径 /api (同源 Express) # build 时把 VITE_API_BASE 写进 bundle, 默认空 = 走相对路径 /api (同源 Express)
ARG VITE_API_BASE= ARG VITE_API_BASE=
ENV VITE_API_BASE=$VITE_API_BASE ENV VITE_API_BASE=$VITE_API_BASE
RUN npm run build RUN if [ "$PREBUILT_DIST" = "true" ]; then \
test -f dist/index.html || { echo "PREBUILT_DIST=true 但 dist/ 缺失"; exit 1; }; \
else \
npm run build; \
fi
# 运行时阶段 # 运行时阶段
FROM node:22-alpine FROM node:22-alpine
@@ -40,7 +52,7 @@ RUN npm config set registry https://registry.npmmirror.com
# 只装运行时依赖 # 只装运行时依赖
COPY package*.json ./ COPY package*.json ./
RUN npm install --omit=dev RUN --mount=type=cache,target=/root/.npm npm ci --omit=dev
# 拷贝构建产物 # 拷贝构建产物
COPY --from=builder /app/dist ./dist COPY --from=builder /app/dist ./dist
+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?
+13 -3
View File
@@ -29,6 +29,8 @@ 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', '')
// 构建对应的 git commit sha (发布说明=commit message, sha 单独一行展示)
const COMMIT = getArg('--commit', '')
const NO_SIGN = args.includes('--no-sign') const NO_SIGN = args.includes('--no-sign')
// 私钥优先来源: MINISIGN_PRIVATE_KEY 环境变量 (CI 注入) → 文件 (--key 路径) // 私钥优先来源: MINISIGN_PRIVATE_KEY 环境变量 (CI 注入) → 文件 (--key 路径)
const KEY_PATH = path.resolve(getArg('--key', path.join(OUT_DIR, 'hotswap.key'))) const KEY_PATH = path.resolve(getArg('--key', path.join(OUT_DIR, 'hotswap.key')))
@@ -117,11 +119,17 @@ const manifest = {
bundle_size: bundleStat.size, bundle_size: bundleStat.size,
bundle_sha256: bundleSha256, bundle_sha256: bundleSha256,
signature, signature,
notes: NOTES, notes: String(NOTES).slice(0, 400),
commit: COMMIT || undefined,
pub_date: new Date().toISOString(), pub_date: new Date().toISOString(),
mandatory: false, mandatory: false,
// 内部用: 前端 web 模式比对 build_id // 内部用: 前端 web 模式比对 build_id。
build_id: 'seq-' + SEQUENCE, // 格式与前端 vite BUILD_ID 一致 (YYYYMMDDHHmmss, 北京时间); sequence 保持毫秒单调递增
build_id: (() => {
const d = new Date(Date.now() + 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())}`
})(),
} }
fs.writeFileSync(path.join(OUT_DIR, 'latest.json'), JSON.stringify(manifest, null, 2)) fs.writeFileSync(path.join(OUT_DIR, 'latest.json'), JSON.stringify(manifest, null, 2))
@@ -131,6 +139,8 @@ console.log('=== OTA 包生成完成 ===')
console.log('输出目录: ', OUT_DIR) console.log('输出目录: ', OUT_DIR)
console.log('版本: ', manifest.version) console.log('版本: ', manifest.version)
console.log('sequence: ', manifest.sequence) console.log('sequence: ', manifest.sequence)
console.log('build_id: ', manifest.build_id)
console.log('commit: ', manifest.commit || '(未指定)')
console.log('bundle 大小: ', (bundleStat.size / 1024).toFixed(1) + ' KB') console.log('bundle 大小: ', (bundleStat.size / 1024).toFixed(1) + ' KB')
console.log('bundle sha256:', bundleSha256) console.log('bundle sha256:', bundleSha256)
console.log('签名: ', signature ? '(已生成, 写入 latest.json)' : '(未签名, --no-sign)') console.log('签名: ', signature ? '(已生成, 写入 latest.json)' : '(未签名, --no-sign)')
+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})`)
} }
} }
+18 -1
View File
@@ -76,6 +76,7 @@ function buildItem(habit, byDate, date, today) {
id: habit.id, id: habit.id,
name: habit.name, name: habit.name,
period: habit.period, period: habit.period,
sortOrder: habit.sortOrder,
periodLabel: PERIOD_LABEL[habit.period] || '每天', periodLabel: PERIOD_LABEL[habit.period] || '每天',
target, target,
color: habit.color, color: habit.color,
@@ -141,8 +142,9 @@ router.post('/habits', async (req, res, next) => {
if (!PERIODS.includes(period)) return res.status(400).json({ error: '周期类型不合法' }) if (!PERIODS.includes(period)) return res.status(400).json({ error: '周期类型不合法' })
const t = Math.floor(Number(target)) const t = Math.floor(Number(target))
if (!Number.isFinite(t) || t < 1) return res.status(400).json({ error: '目标次数需为 ≥1 的整数' }) if (!Number.isFinite(t) || t < 1) return res.status(400).json({ error: '目标次数需为 ≥1 的整数' })
const last = await prisma.habit.findFirst({ where: { userId }, orderBy: { sortOrder: 'desc' }, select: { sortOrder: true } })
const row = await prisma.habit.create({ const row = await prisma.habit.create({
data: { userId, name: cleanName, period, target: t, color: String(color), icon: String(icon), note: String(note || '') }, data: { userId, name: cleanName, period, target: t, color: String(color), icon: String(icon), note: String(note || ''), sortOrder: (last?.sortOrder ?? -1) + 1 },
}) })
res.json({ item: row }) res.json({ item: row })
} catch (e) { next(e) } } catch (e) { next(e) }
@@ -182,6 +184,21 @@ router.put('/habits/:id', async (req, res, next) => {
} catch (e) { next(e) } } catch (e) { next(e) }
}) })
// POST /api/checkin/habits/reorder { ids: [习惯id 的新顺序] } 按数组下标重写 sortOrder
router.post('/habits/reorder', async (req, res, next) => {
try {
const userId = req.user.sub
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 habits = await prisma.habit.findMany({ where: { userId, id: { in: ids } } })
if (habits.length !== new Set(ids).size) return res.status(400).json({ error: '包含无效的打卡项' })
await prisma.$transaction(
ids.map((id, i) => prisma.habit.update({ where: { id }, data: { sortOrder: i } })),
)
res.json({ ok: true })
} catch (e) { next(e) }
})
// DELETE /api/checkin/habits/:id?hard=1 默认归档(保留历史), hard=1 物理删除 // DELETE /api/checkin/habits/:id?hard=1 默认归档(保留历史), hard=1 物理删除
router.delete('/habits/:id', async (req, res, next) => { router.delete('/habits/:id', async (req, res, next) => {
try { try {
+3
View File
@@ -63,6 +63,7 @@ router.get('/ota/check/:current_sequence', (req, res) => {
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 || '',
commit: latest.commit || '',
pub_date: latest.pub_date || new Date().toISOString(), pub_date: latest.pub_date || new Date().toISOString(),
mandatory: !!latest.mandatory, mandatory: !!latest.mandatory,
bundle_size: latest.bundle_size || 0, bundle_size: latest.bundle_size || 0,
@@ -91,6 +92,8 @@ router.get('/ota/manifest-web', (_req, res) => {
res.json({ res.json({
available: true, available: true,
version: latest.version, version: latest.version,
sequence: latest.sequence,
commit: latest.commit || '',
build_id: latest.build_id || latest.version, build_id: latest.build_id || latest.version,
note: latest.notes || '', note: latest.notes || '',
size: latest.bundle_size || 0, size: latest.bundle_size || 0,
+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>} />
+1
View File
@@ -16,6 +16,7 @@ export const checkinApi = {
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 }),
reorderHabits: (ids) => api('/checkin/habits/reorder', { method: 'POST', body: { ids } }),
deleteHabit: (id, hard) => api(`/checkin/habits/${id}${hard ? '?hard=1' : ''}`, { method: 'DELETE' }), deleteHabit: (id, hard) => api(`/checkin/habits/${id}${hard ? '?hard=1' : ''}`, { method: 'DELETE' }),
check: (id, date, note) => api(`/checkin/habits/${id}/check`, { method: 'POST', body: { date, note } }), 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' }), uncheck: (id, date) => api(`/checkin/habits/${id}/check` + q({ date }), { method: 'DELETE' }),
+19
View File
@@ -22,6 +22,19 @@ export function getCurrentVersionInfo() {
return { version: APP_VERSION, build: BUILD_ID } return { version: APP_VERSION, build: BUILD_ID }
} }
// 构建标识统一化: 任意来源 (YYYYMMDDHHmmss / seq-<ms> / 毫秒数) → 'YYYYMMDDHHmmss'
// (北京时间, 与 vite BUILD_ID / OTA manifest.build_id 同格式; 无法解析返回 '')
export function fmtBuildId(v) {
if (v == null) return ''
if (typeof v === 'string' && v.startsWith('seq-')) return fmtBuildId(Number(v.slice(4)))
if (typeof v === 'string' && /^\d{14}$/.test(v)) return v
const ms = Number(v)
if (!Number.isFinite(ms) || ms <= 0) return ''
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())}`
}
// 启动时: 确认当前资源可用 (Tauri only) // 启动时: 确认当前资源可用 (Tauri only)
// 失败/异常都不抛, 仅 console.warn // 失败/异常都不抛, 仅 console.warn
export function initOta() { export function initOta() {
@@ -114,7 +127,9 @@ async function checkByVersion() {
info: { info: {
version: manifest.version, version: manifest.version,
sequence: manifest.sequence, sequence: manifest.sequence,
build: manifest.build_id || fmtBuildId(manifest.sequence),
notes: manifest.notes || '', notes: manifest.notes || '',
commit: manifest.commit || '',
mandatory: !!manifest.mandatory, mandatory: !!manifest.mandatory,
bundle_size: manifest.bundle_size || 0, bundle_size: manifest.bundle_size || 0,
}, },
@@ -135,6 +150,7 @@ async function checkOtaUpdateTauri(devMode) {
info: { info: {
version: r.version, version: r.version,
sequence: r.sequence, sequence: r.sequence,
build: fmtBuildId(r.sequence),
notes: r.notes, notes: r.notes,
mandatory: r.mandatory, mandatory: r.mandatory,
bundle_size: r.bundle_size, bundle_size: r.bundle_size,
@@ -166,7 +182,10 @@ async function checkOtaUpdateWeb(devMode) {
reason: 'ok', reason: 'ok',
info: { info: {
version: data.version, version: data.version,
sequence: data.sequence,
build: fmtBuildId(data.build_id || data.sequence),
notes: data.note || '', notes: data.note || '',
commit: data.commit || '',
// web 端没有强制/mandatory 等字段, 仅展示 // web 端没有强制/mandatory 等字段, 仅展示
mandatory: false, mandatory: false,
bundle_size: data.size || 0, bundle_size: data.size || 0,
+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')
}
}, [])
}
+19 -4
View File
@@ -2,10 +2,10 @@ 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,
@@ -14,6 +14,8 @@ import {
setOtaDevMode, setOtaDevMode,
} from '../api/ota.js' } from '../api/ota.js'
const inTauri = typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window
// "我" 页: 顶部 NavBar + 用户头像卡片 + 信息列表 + OTA 升级区 // "我" 页: 顶部 NavBar + 用户头像卡片 + 信息列表 + OTA 升级区
// OTA 区: // OTA 区:
// - 显示当前 APP_VERSION (前端包, 对应内置 dist) // - 显示当前 APP_VERSION (前端包, 对应内置 dist)
@@ -38,6 +40,16 @@ export default function MePage() {
const [otaInfo, setOtaInfo] = useState(null) // 检查结果中的 info const [otaInfo, setOtaInfo] = useState(null) // 检查结果中的 info
const [otaVersion, setOtaVersion] = useState(null) // hotswap 当前激活的覆盖包版本 const [otaVersion, setOtaVersion] = useState(null) // hotswap 当前激活的覆盖包版本
const [progress, setProgress] = useState({ received: 0, total: 0 }) const [progress, setProgress] = useState({ received: 0, total: 0 })
const [shellVersion, setShellVersion] = useState('') // 外壳 (APK/桌面包) 版本, 独立维护
// 外壳版本: 经 Tauri API 读取 (Web 端为空, 底部显示 —)
useEffect(() => {
if (!inTauri) return
import('@tauri-apps/api/app')
.then((m) => m.getVersion())
.then(setShellVersion)
.catch((e) => console.warn('[shell] getVersion failed:', e))
}, [])
const [error, setError] = useState('') const [error, setError] = useState('')
// 判新模式: false(默认)=版本号比较, true=build sequence 比较; 值持久化在设备端 // 判新模式: false(默认)=版本号比较, true=build sequence 比较; 值持久化在设备端
const [devMode, setDevMode] = useState(() => getOtaDevMode()) const [devMode, setDevMode] = useState(() => getOtaDevMode())
@@ -137,7 +149,7 @@ export default function MePage() {
{/* OTA 升级区 */} {/* OTA 升级区 */}
<List className="me-list" mode="card" header="前端升级 (OTA)"> <List className="me-list" mode="card" header="前端升级 (OTA)">
<List.Item extra={'v' + cur.version + ' · ' + cur.build}>当前版本</List.Item> <List.Item extra={'v' + cur.version + (cur.build ? ' · build ' + cur.build : '')}>当前版本</List.Item>
<List.Item extra={otaVersion?.active ? 'v' + (otaVersion.version || '?') : '内置'}> <List.Item extra={otaVersion?.active ? 'v' + (otaVersion.version || '?') : '内置'}>
运行资源 运行资源
</List.Item> </List.Item>
@@ -197,7 +209,7 @@ export default function MePage() {
</div> </div>
</List.Item> </List.Item>
{otaState === 'available' && otaInfo && ( {otaState === 'available' && otaInfo && (
<List.Item extra={'v' + otaInfo.version}> <List.Item extra={'v' + otaInfo.version + (otaInfo.build ? ' · build ' + otaInfo.build : '')}>
新版本 新版本
{otaInfo.bundle_size ? ` (${(otaInfo.bundle_size / 1024).toFixed(0)} KB)` : ''} {otaInfo.bundle_size ? ` (${(otaInfo.bundle_size / 1024).toFixed(0)} KB)` : ''}
</List.Item> </List.Item>
@@ -217,6 +229,9 @@ export default function MePage() {
{otaInfo?.notes && ( {otaInfo?.notes && (
<List.Item description={otaInfo.notes}>发布说明</List.Item> <List.Item description={otaInfo.notes}>发布说明</List.Item>
)} )}
{otaInfo?.commit && (
<List.Item extra={String(otaInfo.commit).slice(0, 10)}>提交</List.Item>
)}
{error && ( {error && (
<List.Item> <List.Item>
<span className="me-ota-error">{error}</span> <span className="me-ota-error">{error}</span>
@@ -241,7 +256,7 @@ export default function MePage() {
退出登录 退出登录
</List.Item> </List.Item>
</List> </List>
<div className="me-version">v{APP_VERSION} · build {BUILD_ID}</div> <div className="me-version">外壳 v{shellVersion || '—'}</div>
</div> </div>
) )
} }
+104 -48
View File
@@ -3,6 +3,7 @@ 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 useHabitDrag from '../../components/checkin/useHabitDrag.js'
import { checkinApi } from '../../api/checkin.js' import { checkinApi } from '../../api/checkin.js'
import useRefreshOnVisible from '../../hooks/useRefreshOnVisible.js' import useRefreshOnVisible from '../../hooks/useRefreshOnVisible.js'
import { toDateStr, dayLabel } from './date.js' import { toDateStr, dayLabel } from './date.js'
@@ -38,10 +39,45 @@ export default function HabitView({ date, onDateChange }) {
const items = useMemo(() => { const items = useMemo(() => {
const list = data?.items || [] const list = data?.items || []
// 未完成在前, 已完成在后 (F-CK-16 默认排序) // 手动排序优先 (sortOrder), 拖动排序后覆盖 F-CK-16 的"未完成在前"
return [...list].sort((a, b) => Number(a.completed) - Number(b.completed) || a.id - b.id) return [...list].sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0) || a.id - b.id)
}, [data]) }, [data])
// 按周期分组 (每天/每周/每月/每年), 组内顺序 = 手动排序
const groups = useMemo(() => {
const out = []
for (const p of PERIOD_OPTIONS) {
const list = items.filter((i) => i.period === p.value)
if (list.length) out.push({ key: p.value, label: p.label, list })
}
const known = new Set(PERIOD_OPTIONS.map((p) => p.value))
const rest = items.filter((i) => !known.has(i.period))
if (rest.length) out.push({ key: 'other', label: '其他', list: rest })
return out
}, [items])
const drag = useHabitDrag({ onDrop: reorderHabit })
// 组内拖动落位: 重排后按展平顺序整体重写 sortOrder
async function reorderHabit(dragged, targetId, after) {
const snapshot = data
const g = groups.find((x) => x.key === dragged.period)
if (!g) return
const rest = g.list.filter((i) => i.id !== dragged.id)
const ti = rest.findIndex((i) => i.id === targetId)
if (ti < 0) return
rest.splice(after ? ti + 1 : ti, 0, dragged)
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 {
await checkinApi.reorderHabits(flat.map((i) => i.id))
} catch (e) {
setData(snapshot)
Toast.show({ content: e.message || '排序保存失败' })
}
}
const fireBurst = (id) => setBursts((m) => ({ ...m, [id]: (m[id] || 0) + 1 })) const fireBurst = (id) => setBursts((m) => ({ ...m, [id]: (m[id] || 0) + 1 }))
// 乐观更新: 先本地 +1, 成功用服务端结果覆盖, 失败回滚 (F-SY-06/E-02) // 乐观更新: 先本地 +1, 成功用服务端结果覆盖, 失败回滚 (F-SY-06/E-02)
@@ -169,54 +205,74 @@ export default function HabitView({ date, onDateChange }) {
)} )}
<div className="habit-list"> <div className="habit-list">
{items.map((item) => ( {groups.map((g) => (
<div key={item.id} className={'habit-card' + (item.completed ? ' done' : '')}> <div key={g.key}>
<RingProgress <div className="habit-group-title">
percent={item.percent} {g.label}
done={item.done} <span className="cnt">{g.list.length}</span>
target={item.target}
over={item.over}
completed={item.completed}
color={item.color}
onClick={() => doCheck(item)}
/>
<div className="habit-main">
<div className="habit-name">{item.name}</div>
<div className="habit-meta">
{item.periodLabel} {item.target} 次
{item.streak > 0 && <span className="habit-streak">连续 {item.streak}</span>}
</div>
{item.note && <div className="habit-note">{item.note}</div>}
<div className="habit-range">
{item.periodStart === item.periodEnd
? '今日'
: `${item.periodStart.slice(5)} ~ ${item.periodEnd.slice(5)}`}
{item.over > 0 && <span className="habit-over">超额 {item.over}</span>}
</div>
</div> </div>
<div className="habit-actions"> {g.list.map((item) => (
<Button <div
size="mini" key={item.id}
color={item.completed ? 'default' : 'primary'} data-habit-row
onClick={() => doCheck(item)} data-habit-id={item.id}
className={
'habit-card'
+ (item.completed ? ' done' : '')
+ (drag.dragItem?.id === item.id ? ' dragging' : '')
+ (drag.over?.id === item.id ? ` dragover ${drag.over.after ? 'after' : 'before'}` : '')
}
{...drag.bind(item)}
> >
{item.completed ? '再打一次' : '打卡'} <RingProgress
</Button> percent={item.percent}
</div> done={item.done}
<div className="habit-foot"> target={item.target}
{item.done > 0 && ( over={item.over}
<span className="habit-link" onClick={() => doUncheck(item)}>撤销</span> completed={item.completed}
)} color={item.color}
<span onClick={() => { if (!drag.justEnded()) doCheck(item) }}
className="habit-link" />
onClick={() => { <div className="habit-main">
setForm({ id: item.id, name: item.name, period: item.period, target: item.target, color: item.color, note: item.note }) <div className="habit-name">{item.name}</div>
setShowForm(true) <div className="habit-meta">
}} {item.periodLabel} {item.target} 次
>编辑</span> {item.streak > 0 && <span className="habit-streak">连续 {item.streak}</span>}
<span className="habit-link danger" onClick={() => removeHabit(item)}>归档</span> </div>
</div> {item.note && <div className="habit-note">{item.note}</div>}
<Celebrate fire={bursts[item.id] || 0} color={item.color} /> <div className="habit-range">
{item.periodStart === item.periodEnd
? '今日'
: `${item.periodStart.slice(5)} ~ ${item.periodEnd.slice(5)}`}
{item.over > 0 && <span className="habit-over">超额 {item.over}</span>}
</div>
</div>
<div className="habit-actions">
<Button
size="mini"
color={item.completed ? 'default' : 'primary'}
onClick={() => { if (!drag.justEnded()) doCheck(item) }}
>
{item.completed ? '再打一次' : '打卡'}
</Button>
</div>
<div className="habit-foot">
{item.done > 0 && (
<span className="habit-link" onClick={() => { if (!drag.justEnded()) doUncheck(item) }}>撤销</span>
)}
<span
className="habit-link"
onClick={() => {
if (drag.justEnded()) return
setForm({ id: item.id, name: item.name, period: item.period, target: item.target, color: item.color, note: item.note })
setShowForm(true)
}}
>编辑</span>
<span className="habit-link danger" onClick={() => { if (!drag.justEnded()) removeHabit(item) }}>归档</span>
</div>
<Celebrate fire={bursts[item.id] || 0} color={item.color} />
</div>
))}
</div> </div>
))} ))}
</div> </div>
+50 -5
View File
@@ -9,6 +9,7 @@ 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 useHabitDrag from '../../components/checkin/useHabitDrag.js'
import { checkinApi } from '../../api/checkin.js' import { checkinApi } from '../../api/checkin.js'
import useRefreshOnVisible from '../../hooks/useRefreshOnVisible.js' import useRefreshOnVisible from '../../hooks/useRefreshOnVisible.js'
import { import {
@@ -77,6 +78,8 @@ export default function MergedView({ date, onDateChange }) {
// 按周期分组: 每天 → 每周 → 每月. "每年" 不在合并视图中展示, 但若存在 // 按周期分组: 每天 → 每周 → 每月. "每年" 不在合并视图中展示, 但若存在
// 历史 year 项则归入"每月"之外的单列, 避免数据凭空消失. // 历史 year 项则归入"每月"之外的单列, 避免数据凭空消失.
// 组内顺序: 手动排序 (sortOrder) 优先 —— 拖动排序需要稳定口径,
// 服务端 /habits 已按 sortOrder 返回, 此处仅兜底。
const groups = useMemo(() => { const groups = useMemo(() => {
const bucket = new Map() const bucket = new Map()
for (const it of items) { for (const it of items) {
@@ -84,9 +87,8 @@ export default function MergedView({ date, onDateChange }) {
if (!bucket.has(key)) bucket.set(key, []) if (!bucket.has(key)) bucket.set(key, [])
bucket.get(key).push(it) bucket.get(key).push(it)
} }
// 组内排序: 未完成在前, 已完成在后 (沿用 F-CK-16 默认口径)
for (const arr of bucket.values()) { for (const arr of bucket.values()) {
arr.sort((a, b) => Number(a.completed) - Number(b.completed) || a.id - b.id) arr.sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0) || a.id - b.id)
} }
const order = [...GROUP_ORDER, 'other'] const order = [...GROUP_ORDER, 'other']
return order.filter((k) => bucket.has(k)).map((k) => ({ return order.filter((k) => bucket.has(k)).map((k) => ({
@@ -96,6 +98,37 @@ export default function MergedView({ date, onDateChange }) {
})) }))
}, [items]) }, [items])
const drag = useHabitDrag({ onDrop: reorderHabit })
// 拖动落位: 组内排序; 落到其他周期组 = 同时改周期并落位。
// 重排后按渲染分组顺序整体展平重写 sortOrder。
async function reorderHabit(dragged, targetId, after, targetPeriod) {
const snapshot = detail
const g = groups.find((x) => x.items.some((i) => i.id === targetId))
if (!g) return
const target = g.items.find((i) => i.id === targetId)
const rest = g.items.filter((i) => i.id !== dragged.id)
const ti = rest.findIndex((i) => i.id === targetId)
if (ti < 0) return
const crossed = targetPeriod && targetPeriod !== dragged.period
const moved = crossed
? { ...dragged, period: targetPeriod, periodLabel: GROUP_LABEL[targetPeriod] || target.periodLabel }
: dragged
rest.splice(after ? ti + 1 : ti, 0, moved)
// 展平时把拖动项从原组剔除 (跨组场景原组可能仍包含它)
const flat = groups
.map((x) => (x.key === g.key ? rest : x.items.filter((i) => i.id !== dragged.id)))
.flat()
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))
} catch (e) {
setDetail(snapshot)
Toast.show({ content: e.message || '排序保存失败' })
}
}
const summary = detail?.summary || { total: 0, done: 0 } const summary = detail?.summary || { total: 0, done: 0 }
const fireBurst = (id) => setBursts((m) => ({ ...m, [id]: (m[id] || 0) + 1 })) const fireBurst = (id) => setBursts((m) => ({ ...m, [id]: (m[id] || 0) + 1 }))
@@ -290,7 +323,19 @@ export default function MergedView({ date, onDateChange }) {
<div key={g.key}> <div key={g.key}>
<div className="mv-ghead">{g.label}<span className="cnt">{g.items.length} 项</span></div> <div className="mv-ghead">{g.label}<span className="cnt">{g.items.length} 项</span></div>
{g.items.map((item) => ( {g.items.map((item) => (
<div key={item.id} className={'mv-card' + (item.completed ? ' done' : '')}> <div
key={item.id}
data-habit-row
data-habit-id={item.id}
data-habit-period={item.period}
className={
'mv-card'
+ (item.completed ? ' done' : '')
+ (drag.dragItem?.id === item.id ? ' dragging' : '')
+ (drag.over?.id === item.id ? ` dragover ${drag.over.after ? 'after' : 'before'}` : '')
}
>
<span className="grip" title="拖动排序" {...drag.bindHandle(item)}>⠿</span>
<RingProgress <RingProgress
size={42} size={42}
stroke={5} stroke={5}
@@ -300,7 +345,7 @@ export default function MergedView({ date, onDateChange }) {
over={item.over} over={item.over}
completed={item.completed} completed={item.completed}
color={item.color} color={item.color}
onClick={() => doCheck(item)} onClick={() => { if (!drag.justEnded()) doCheck(item) }}
/> />
<div className="mv-main"> <div className="mv-main">
<div className="mv-name">{item.name}</div> <div className="mv-name">{item.name}</div>
@@ -314,7 +359,7 @@ export default function MergedView({ date, onDateChange }) {
) : ( ) : (
<div <div
className={'mv-btn' + (item.completed ? ' grey' : '')} className={'mv-btn' + (item.completed ? ' grey' : '')}
onClick={() => doCheck(item)} onClick={() => { if (!drag.justEnded()) doCheck(item) }}
> >
{item.completed ? '再打一次' : '打卡'} {item.completed ? '再打一次' : '打卡'}
</div> </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 }
}
+170 -37
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 {
@@ -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));
overflow-y: auto;
padding: 16px 16px calc(env(safe-area-inset-bottom, 0px) + 20px + var(--kb-inset, 0px));
} }
.task-content input, .task-content textarea { -webkit-user-select: text; user-select: text; }
/* ---- 拖动方案 (重点设计 §7.1) ---- .bcard, .task-row { touch-action: pan-y; }
卡片允许纵向滚动(PAN-Y), 水平位移留给拖动; 激活后原位抬起 */ .bcard.dragging {
.bcard { touch-action: pan-y; } opacity: .35; outline: 2px dashed var(--color-primary); outline-offset: 1px;
.bcard.dragging { opacity: .5; transform: scale(.97); box-shadow: 0 10px 22px rgba(15,23,42,.22); z-index: 6; } }
.task-row.dragging {
opacity: .35; outline: 2px dashed var(--color-primary); outline-offset: 1px;
}
.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; }