diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..fe773e5 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,11 @@ +# 大目录/与镜像无关的内容不进 build context +# 注意: dist/ 和 ota-package/ 不能忽略 —— CI 预构建产物要通过 context 进入镜像 +.git +.gitea +.local +.pnpm-store +node_modules +src-tauri +docs +*.log +readme diff --git a/.gitea/workflows/android-build.yml b/.gitea/workflows/android-build.yml index 72cb31e..2571cc5 100644 --- a/.gitea/workflows/android-build.yml +++ b/.gitea/workflows/android-build.yml @@ -1,182 +1,190 @@ -name: Android Build & Publish - -# 仅在 android-* 分支 push 时触发 (如 android/release, android-1.0) -# 构建 release APK (keystore 签名) 并上传到 Gitea Generic Package Registry -on: - push: - branches: - - 'android-*' - -jobs: - build-android: - runs-on: ubuntu-latest - # 需要写权限: 上传 packages - permissions: - contents: read - packages: write - - env: - # 固定工具链版本, 便于 actions/cache 命中 - # Android 后端地址 (bake 进 APK); 与 scripts/wsl/build-apk.sh 保持一致 - VITE_API_BASE: http://bh.vps.honor3.com:3001 - - steps: - - name: Checkout - uses: https://gitea.com/actions/checkout@v4 - - - name: Setup Node - uses: https://gitea.com/actions/setup-node@v4 - with: - node-version: 22 - cache: 'npm' - - # 版本号一致性: package.json / tauri.conf.json / Cargo.toml / Cargo.lock 必须完全相等。 - # tauri.conf.json 的 version 就是 APK 的 versionName, 漏改会打出一个版本号错误的安装包。 - - name: Check version consistency - run: node scripts/ci/check-version.mjs - - - name: Setup JDK 17 - uses: https://gitea.com/actions/setup-java@v4 - with: - distribution: temurin - java-version: '17' - - # ---------- 缓存: 命中后跳过下面的安装 ---------- - - name: Cache Android SDK & NDK - id: cache-android - uses: https://gitea.com/actions/cache@v4 - with: - path: ~/android-sdk - key: android-sdk-ndk-27.2-${{ runner.os }} - - - name: Cache Rust toolchain - id: cache-rust - uses: https://gitea.com/actions/cache@v4 - with: - path: | - ~/.cargo - ~/.rustup - # 工具链升级时 bump 版本号使缓存失效重装 (含 cargo-ndk) - key: rust-android-v1-${{ runner.os }} - - - name: Cache Tauri cargo target - uses: https://gitea.com/actions/cache@v4 - with: - path: src-tauri/target - key: tauri-target-android-${{ runner.os }} - - # ---------- 安装工具链 (仅缓存未命中时) ---------- - - name: Install Rust + Android targets - if: steps.cache-rust.outputs.cache-hit != 'true' - run: | - curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable - echo "$HOME/.cargo/bin" >> $GITHUB_PATH - . "$HOME/.cargo/env" - rustup target add aarch64-linux-android armv7-linux-androideabi x86_64-linux-android i686-linux-android - cargo install cargo-ndk --locked - - - name: Install Android SDK & NDK - if: steps.cache-android.outputs.cache-hit != 'true' - run: | - export ANDROID_HOME="$HOME/android-sdk" - sudo apt-get update - sudo apt-get install -y --no-install-recommends unzip - mkdir -p "$ANDROID_HOME/cmdline-tools" - wget -q https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip -O /tmp/cmdline-tools.zip - unzip -q /tmp/cmdline-tools.zip -d /tmp/ - mv /tmp/cmdline-tools "$ANDROID_HOME/cmdline-tools/latest" - export PATH="$ANDROID_HOME/cmdline-tools/latest/bin:$PATH" - yes | sdkmanager --licenses >/dev/null || true - sdkmanager --install \ - "platforms;android-36" \ - "build-tools;36.0.0" \ - "platform-tools" \ - "ndk;27.2.12479018" \ - "cmake;3.22.1" - - - name: Install frontend deps - run: npm ci --no-audit --no-fund - - # ---------- 全局 Android 环境 (对后续所有 step 生效, 无论缓存是否命中) ---------- - - name: Set Android env - run: | - echo "ANDROID_HOME=$HOME/android-sdk" >> "$GITHUB_ENV" - echo "ANDROID_NDK_HOME=$HOME/android-sdk/ndk/27.2.12479018" >> "$GITHUB_ENV" - echo "$HOME/android-sdk/cmdline-tools/latest/bin" >> "$GITHUB_PATH" - echo "$HOME/android-sdk/platform-tools" >> "$GITHUB_PATH" - - # ---------- 构建 ---------- - - name: Init Android project - run: | - # tauri android init 需要 cargo (运行 cargo metadata)。 - # Rust 工具链缓存命中时 "Install Rust" 步骤被跳过, 不会把 cargo 加进全局 PATH, - # 因此这里显式加载 cargo 环境 (与 Build release APK 步骤保持一致)。 - . "$HOME/.cargo/env" - cd src-tauri - npx tauri android init --ci - - - name: Inject release signing config - run: bash scripts/ci/android-signing.sh - env: - ANDROID_KEYSTORE_B64: ${{ secrets.ANDROID_KEYSTORE_B64 }} - ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }} - ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }} - - - name: Build release APK - run: | - export ANDROID_HOME="$HOME/android-sdk" - export ANDROID_NDK_HOME="$ANDROID_HOME/ndk/27.2.12479018" - export PATH="$ANDROID_HOME/cmdline-tools/latest/bin:$ANDROID_HOME/platform-tools:$ANDROID_NDK_HOME/toolchains/llvm/prebuilt/linux-x86_64/bin:$PATH" - export CARGO_HOME="$HOME/.cargo" - . "$HOME/.cargo/env" - cd src-tauri - npx tauri android build --apk - # 断言签名配置仍在且 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 - echo "ERROR: 构建后签名配置丢失/未生效, APK 未签名" >&2 - exit 1 - fi - - - name: Locate APK - id: apk - run: | - # 优先 universal 已签名 APK, 找不到再回退到其他已签名 APK - APK="" - UNIVERSAL=$(find src-tauri/gen/android/app/build/outputs/apk/universal/release -name '*.apk' ! -name '*unsigned*' 2>/dev/null | head -1) - if [ -n "$UNIVERSAL" ]; then - APK="$UNIVERSAL" - else - APK=$(find src-tauri/gen/android/app/build/outputs/apk -name '*.apk' ! -name '*unsigned*' | head -1) - fi - if [ -z "$APK" ]; then - echo "ERROR: 未找到已签名 APK" >&2 - exit 1 - fi - echo "path=$APK" >> $GITHUB_OUTPUT - ls -lh "$APK" - - # ---------- 上传到 Gitea Generic Package ---------- - - name: Publish APK to Gitea packages - env: - # 注意: 不能用 GITEA_TOKEN (内置 job token, 无组织 packages 写权限, 且命名规则禁止 GITEA_ 前缀) - # 用一个自定义名 secret 存放有 packages 写权限的 user token - PACKAGE_TOKEN: ${{ secrets.PACKAGE_TOKEN }} - APK_PATH: ${{ steps.apk.outputs.path }} - run: | - set -euo pipefail - # 包版本: 用分支名去掉 android- 前缀 + 短 SHA, 保证每次 push 唯一 - BRANCH="${GITHUB_REF_NAME#android-}" - PKG_VERSION="${BRANCH}-${GITHUB_SHA:0:8}" - FILE_NAME="iboard-$(basename "$APK_PATH")" - OWNER="${GITHUB_REPOSITORY_OWNER}" - PKG_NAME="iboard" - # Gitea Generic Package Registry 上传端点 (PUT) - # 注意: GITHUB_SERVER_URL 是仓库 web 根地址, 例如 http://git.honor3.com - URL="${GITHUB_SERVER_URL}/api/packages/${OWNER}/generic/${PKG_NAME}/${PKG_VERSION}/${FILE_NAME}" - echo "上传到: ${URL}" - curl -sS --fail-with-body \ - -H "Authorization: token ${PACKAGE_TOKEN}" \ - --upload-file "$APK_PATH" \ - "${URL}" - echo "" - echo "已发布: ${URL}" +name: Android Build & Publish + +# 仅在 android-* 分支 push 时触发 (如 android/release, android-1.0) +# 构建 release APK (keystore 签名) 并上传到 Gitea Generic Package Registry +on: + push: + branches: + - 'android-*' + +# 同分支连续 push 时取消旧的构建, 省资源也避免上传过时 APK +concurrency: + group: android-${{ github.ref_name }} + cancel-in-progress: true + +jobs: + build-android: + runs-on: ubuntu-latest + # 需要写权限: 上传 packages + permissions: + contents: read + packages: write + + env: + # 固定工具链版本, 便于 actions/cache 命中 + # Android 后端地址 (bake 进 APK); 与 scripts/wsl/build-apk.sh 保持一致 + VITE_API_BASE: http://bh.vps.honor3.com:3001 + + steps: + - name: Checkout + uses: https://gitea.com/actions/checkout@v4 + + - name: Setup Node + uses: https://gitea.com/actions/setup-node@v4 + with: + node-version: 22 + cache: 'npm' + + # 版本号一致性: package.json / tauri.conf.json / Cargo.toml / Cargo.lock 必须完全相等。 + # tauri.conf.json 的 version 就是 APK 的 versionName, 漏改会打出一个版本号错误的安装包。 + - name: Check version consistency + run: node scripts/ci/check-version.mjs + + - name: Setup JDK 17 + uses: https://gitea.com/actions/setup-java@v4 + with: + distribution: temurin + java-version: '17' + + # ---------- 缓存: 命中后跳过下面的安装 ---------- + - name: Cache Android SDK & NDK + id: cache-android + uses: https://gitea.com/actions/cache@v4 + with: + path: ~/android-sdk + key: android-sdk-ndk-27.2-${{ runner.os }} + + - name: Cache Rust toolchain + id: cache-rust + uses: https://gitea.com/actions/cache@v4 + with: + path: | + ~/.cargo + ~/.rustup/toolchains + # 工具链升级时 bump 版本号使缓存失效重装 (含 cargo-ndk) + key: rust-android-v1-${{ runner.os }} + + - name: Cache Tauri cargo target + uses: https://gitea.com/actions/cache@v4 + with: + path: src-tauri/target + # 依赖变化 → 新 key; 命不中时回退最近一次的 target 目录做增量编译 + key: tauri-target-android-${{ hashFiles('src-tauri/Cargo.lock') }} + restore-keys: tauri-target-android- + + # ---------- 安装工具链 (仅缓存未命中时) ---------- + - name: Install Rust + Android targets + if: steps.cache-rust.outputs.cache-hit != 'true' + run: | + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable + echo "$HOME/.cargo/bin" >> $GITHUB_PATH + . "$HOME/.cargo/env" + # 只构建 aarch64 (现代 Android 手机); 需要模拟器/x86 设备时再加回其他 target + rustup target add aarch64-linux-android + cargo install cargo-ndk --locked + + - name: Install Android SDK & NDK + if: steps.cache-android.outputs.cache-hit != 'true' + run: | + export ANDROID_HOME="$HOME/android-sdk" + sudo apt-get update + sudo apt-get install -y --no-install-recommends unzip + mkdir -p "$ANDROID_HOME/cmdline-tools" + wget -q https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip -O /tmp/cmdline-tools.zip + unzip -q /tmp/cmdline-tools.zip -d /tmp/ + mv /tmp/cmdline-tools "$ANDROID_HOME/cmdline-tools/latest" + export PATH="$ANDROID_HOME/cmdline-tools/latest/bin:$PATH" + yes | sdkmanager --licenses >/dev/null || true + sdkmanager --install \ + "platforms;android-36" \ + "build-tools;36.0.0" \ + "platform-tools" \ + "ndk;27.2.12479018" \ + "cmake;3.22.1" + + - name: Install frontend deps + run: npm ci --no-audit --no-fund + + # ---------- 全局 Android 环境 (对后续所有 step 生效, 无论缓存是否命中) ---------- + - name: Set Android env + run: | + echo "ANDROID_HOME=$HOME/android-sdk" >> "$GITHUB_ENV" + echo "ANDROID_NDK_HOME=$HOME/android-sdk/ndk/27.2.12479018" >> "$GITHUB_ENV" + echo "$HOME/android-sdk/cmdline-tools/latest/bin" >> "$GITHUB_PATH" + echo "$HOME/android-sdk/platform-tools" >> "$GITHUB_PATH" + + # ---------- 构建 ---------- + - name: Init Android project + run: | + # tauri android init 需要 cargo (运行 cargo metadata)。 + # Rust 工具链缓存命中时 "Install Rust" 步骤被跳过, 不会把 cargo 加进全局 PATH, + # 因此这里显式加载 cargo 环境 (与 Build release APK 步骤保持一致)。 + . "$HOME/.cargo/env" + cd src-tauri + npx tauri android init --ci + + - name: Inject release signing config + run: bash scripts/ci/android-signing.sh + env: + ANDROID_KEYSTORE_B64: ${{ secrets.ANDROID_KEYSTORE_B64 }} + ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }} + ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }} + + - name: Build release APK + run: | + export ANDROID_HOME="$HOME/android-sdk" + export ANDROID_NDK_HOME="$ANDROID_HOME/ndk/27.2.12479018" + export PATH="$ANDROID_HOME/cmdline-tools/latest/bin:$ANDROID_HOME/platform-tools:$ANDROID_NDK_HOME/toolchains/llvm/prebuilt/linux-x86_64/bin:$PATH" + export CARGO_HOME="$HOME/.cargo" + . "$HOME/.cargo/env" + cd src-tauri + npx tauri android build --apk --target aarch64 + # 断言签名配置仍在且 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 + echo "ERROR: 构建后签名配置丢失/未生效, APK 未签名" >&2 + exit 1 + fi + + - name: Locate APK + id: apk + run: | + # 优先 universal 已签名 APK, 找不到再回退到其他已签名 APK + APK="" + UNIVERSAL=$(find src-tauri/gen/android/app/build/outputs/apk/universal/release -name '*.apk' ! -name '*unsigned*' 2>/dev/null | head -1) + if [ -n "$UNIVERSAL" ]; then + APK="$UNIVERSAL" + else + APK=$(find src-tauri/gen/android/app/build/outputs/apk -name '*.apk' ! -name '*unsigned*' | head -1) + fi + if [ -z "$APK" ]; then + echo "ERROR: 未找到已签名 APK" >&2 + exit 1 + fi + echo "path=$APK" >> $GITHUB_OUTPUT + ls -lh "$APK" + + # ---------- 上传到 Gitea Generic Package ---------- + - name: Publish APK to Gitea packages + env: + # 注意: 不能用 GITEA_TOKEN (内置 job token, 无组织 packages 写权限, 且命名规则禁止 GITEA_ 前缀) + # 用一个自定义名 secret 存放有 packages 写权限的 user token + PACKAGE_TOKEN: ${{ secrets.PACKAGE_TOKEN }} + APK_PATH: ${{ steps.apk.outputs.path }} + run: | + set -euo pipefail + # 包版本: 用分支名去掉 android- 前缀 + 短 SHA, 保证每次 push 唯一 + BRANCH="${GITHUB_REF_NAME#android-}" + PKG_VERSION="${BRANCH}-${GITHUB_SHA:0:8}" + FILE_NAME="iboard-$(basename "$APK_PATH")" + OWNER="${GITHUB_REPOSITORY_OWNER}" + PKG_NAME="iboard" + # Gitea Generic Package Registry 上传端点 (PUT) + # 注意: GITHUB_SERVER_URL 是仓库 web 根地址, 例如 http://git.honor3.com + URL="${GITHUB_SERVER_URL}/api/packages/${OWNER}/generic/${PKG_NAME}/${PKG_VERSION}/${FILE_NAME}" + echo "上传到: ${URL}" + curl -sS --fail-with-body \ + -H "Authorization: token ${PACKAGE_TOKEN}" \ + --upload-file "$APK_PATH" \ + "${URL}" + echo "" + echo "已发布: ${URL}" diff --git a/.gitea/workflows/docker-build.yml b/.gitea/workflows/docker-build.yml index c40afb3..0990807 100644 --- a/.gitea/workflows/docker-build.yml +++ b/.gitea/workflows/docker-build.yml @@ -1,114 +1,149 @@ -name: Docker Build and Push - -# 仅主分支 (master) push 触发部署; 其他分支一律不触发 (见 readme「分支规则」) -# 每次提交: 构建前端 + 生成 OTA 升级包 (签名) → 打进 Docker 镜像 → 自动升级数据库 → 部署 -# OTA 随镜像发布: 容器内 OTA_PACKAGE_DIR=/app/ota-package 指向打包的 ota-package/ -# -# 分支策略 (2026-10 调整): -# - master = 主分支, 唯一部署出口, push 即发布生产 (容器 iboard, 宿主 3001) -# - dev-* = 开发集成分支, 只做 CI 校验 (构建/版本一致性), **不再自动部署**; -# 要上线必须先合并到 master 再推 master -# - tmp-* = 单个问题的临时分支, 合到 dev-* 后删除 -# - android-*= 只用于触发 APK 外壳构建, 不部署; 改动前须与 master 同步 -# -# 历史: 之前 dev-* 与 master 共用同一套生产环境 (推送即发布), 容易把半成品带上线, -# 现已取消 dev-* 的 push 触发。 -on: - push: - branches: - - master - -jobs: - build-image: - runs-on: ubuntu-latest - env: - # 关键: 显式固定 Android/OTA 后端地址, 覆盖 runner 环境里可能存在的 - # 其他 VITE_API_BASE 值。否则 OTA 包里的前端可能连错后端, 导致 - # "OTA 更新后登录失败/进不去内部"。与 android-build.yml 保持一致。 - VITE_API_BASE: http://bh.vps.honor3.com:3001 - # OTA bundle 要求的最低原生二进制版本。 - # 与 package.json 的应用版本是**两个概念**: 前端 OTA 不碰原生层, - # 现有 APK 仍是 0.1.0, 所以这里固定 0.1.0 (否则旧 APK 会被判定为不兼容而收不到更新)。 - # 重打 APK 并确认原生层无破坏后, 再提升此值。 - OTA_MIN_BINARY: '0.1.0' - steps: - - uses: https://gitea.com/actions/checkout@v4 - - - name: Setup Node - uses: https://gitea.com/actions/setup-node@v4 - with: - node-version: 22 - - - name: Check version consistency & branch - # 1) package.json / tauri.conf.json / Cargo.toml / Cargo.lock 四处版本必须完全相等且不带 -ota - # (tauri.conf.json 的 version 就是 APK 的 versionName) - # 2) 防止 OTA 版本号与分支错位: OTA 包的 version 直接取自 package.json, - # dev-0.2 分支上必须是 0.2.x, 否则线上 OTA 会报成上一个版本的号 - # (master 分支跳过分支前缀校验, 但四处一致性照样强制) - env: - GITHUB_REF_NAME: ${{ github.ref_name }} - run: node scripts/ci/check-version.mjs - - - name: Install deps - run: npm ci --no-audit --no-fund - - - name: Build frontend - # dist/ 供 OTA 打包用 (Dockerfile 内也会构建一份) - run: npm run build - - - name: Install minisign - run: sudo apt-get update && sudo apt-get install -y minisign - - - name: Build & sign OTA package - # 每次主分支提交生成 OTA 升级包, sequence 默认时间戳 (单调递增) - # 产物 ota-package/ (tar.gz + latest.json + .minisig) 进入 docker build context, 由 Dockerfile COPY 进镜像 - env: - MINISIGN_PRIVATE_KEY: ${{ secrets.MINISIGN_PRIVATE_KEY }} - OTA_NOTES: ${{ github.sha }} - run: | - set -euo pipefail - node scripts/build-ota.mjs --out ota-package --notes ${OTA_NOTES} --min-binary ${OTA_MIN_BINARY} - echo "===== latest.json =====" - cat ota-package/latest.json - echo "===== bundle sha256 =====" - sha256sum ota-package/iboard-ota.tar.gz - - - name: Build Docker image - # DATABASE_URL 仅在 prisma generate 阶段需要, 可用任何合法占位串 - run: docker build -t iboard:latest --build-arg DATABASE_URL=mysql://build:build@127.0.0.1:3306/build . - - - name: List images - run: docker images - - - name: Apply DB migrations - # 数据库自动升级: 按 prisma/migrations/ 下的迁移顺序应用 (生产库由 master 部署时统一升级) - # migrate deploy 幂等, 无新迁移时为 no-op - # 位置关键: 在 "Remove old container" 之前 —— 建表/改表成功后才切换流量, - # 迁移失败则整个 workflow 中断, 不会用新镜像顶掉仍在运行的旧容器 - env: - DATABASE_URL: ${{ secrets.DATABASE_URL }} - run: | - set -euo pipefail - npx prisma migrate status || true - npx prisma migrate deploy - - - name: Remove old container - run: docker rm -vf iboard || true - - - name: Recycle container - run: docker system prune --volumes -f - - - name: Run container - # DATABASE_URL 通过 gitea Actions secret 注入, 不进镜像层 - # OTA_PACKAGE_DIR 已在 Dockerfile 设为 /app/ota-package - # 米家凭证 (auth.txt) 不进 git: 运维放宿主机 /app/iboard/cache, bind-mount 进容器 (MIHOME_CACHE_DIR=/app/cache) - run: | - docker run -d --name iboard -p 3001:3001 \ - -e DATABASE_URL="${{ secrets.DATABASE_URL }}" \ - -e JWT_SECRET="${{ secrets.JWT_SECRET }}" \ - -e MIHOME_CACHE_DIR=/app/cache \ - -v /app/iboard/cache:/app/cache \ - iboard:latest - env: - DATABASE_URL: ${{ secrets.DATABASE_URL }} - JWT_SECRET: ${{ secrets.JWT_SECRET }} +name: Docker Build and Push + +# 仅主分支 (master) push 触发部署; 其他分支一律不触发 (见 readme「分支规则」) +# 每次提交: 构建前端 + 生成 OTA 升级包 (签名) → 打进 Docker 镜像 → 自动升级数据库 → 部署 +# OTA 随镜像发布: 容器内 OTA_PACKAGE_DIR=/app/ota-package 指向打包的 ota-package/ +# +# 分支策略 (2026-10 调整): +# - master = 主分支, 唯一部署出口, push 即发布生产 (容器 iboard, 宿主 3001) +# - dev-* = 开发集成分支, 只做 CI 校验 (构建/版本一致性), **不再自动部署**; +# 要上线必须先合并到 master 再推 master +# - tmp-* = 单个问题的临时分支, 合到 dev-* 后删除 +# - android-*= 只用于触发 APK 外壳构建, 不部署; 改动前须与 master 同步 +# +# 历史: 之前 dev-* 与 master 共用同一套生产环境 (推送即发布), 容易把半成品带上线, +# 现已取消 dev-* 的 push 触发。 +on: + push: + branches: + - master + +# 连续 push master 时取消还在跑的旧部署, 避免排队执行过时版本的完整部署 +concurrency: + group: deploy-master + cancel-in-progress: true + +jobs: + build-image: + runs-on: ubuntu-latest + env: + # 关键: 显式固定 Android/OTA 后端地址, 覆盖 runner 环境里可能存在的 + # 其他 VITE_API_BASE 值。否则 OTA 包里的前端可能连错后端, 导致 + # "OTA 更新后登录失败/进不去内部"。与 android-build.yml 保持一致。 + VITE_API_BASE: http://bh.vps.honor3.com:3001 + # OTA bundle 要求的最低原生二进制版本。 + # 与 package.json 的应用版本是**两个概念**: 前端 OTA 不碰原生层, + # 现有 APK 仍是 0.1.0, 所以这里固定 0.1.0 (否则旧 APK 会被判定为不兼容而收不到更新)。 + # 重打 APK 并确认原生层无破坏后, 再提升此值。 + OTA_MIN_BINARY: '0.1.0' + steps: + - uses: https://gitea.com/actions/checkout@v4 + + - name: Setup Node + uses: https://gitea.com/actions/setup-node@v4 + with: + node-version: 22 + cache: 'npm' + + - name: Check version consistency & branch + # 1) package.json / tauri.conf.json / Cargo.toml / Cargo.lock 四处版本必须完全相等且不带 -ota + # (tauri.conf.json 的 version 就是 APK 的 versionName) + # 2) 防止 OTA 版本号与分支错位: OTA 包的 version 直接取自 package.json, + # dev-0.2 分支上必须是 0.2.x, 否则线上 OTA 会报成上一个版本的号 + # (master 分支跳过分支前缀校验, 但四处一致性照样强制) + env: + GITHUB_REF_NAME: ${{ github.ref_name }} + run: node scripts/ci/check-version.mjs + + - name: Install deps + run: npm ci --no-audit --no-fund + + - name: Build frontend + # 全流程唯一一次前端构建: dist/ 既供 OTA 打包, 也通过 build context 进入镜像 + # (docker build 传 PREBUILT_DIST=true 跳过镜像内的重复构建) + run: npm run build + + - name: Install 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 + # 每次主分支提交生成 OTA 升级包, sequence 默认时间戳 (单调递增) + # 发布说明 = 触发构建的 commit message (标题+正文; 打包脚本内安全截断 400 字符), + # commit sha 通过 --commit 单独写入 manifest, 前端分行展示 + # 产物 ota-package/ (tar.gz + latest.json + .minisig) 进入 docker build context, 由 Dockerfile COPY 进镜像 + env: + MINISIGN_PRIVATE_KEY: ${{ secrets.MINISIGN_PRIVATE_KEY }} + run: | + set -euo pipefail + # 不用 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 =====" + cat ota-package/latest.json + echo "===== bundle sha256 =====" + sha256sum ota-package/iboard-ota.tar.gz + + - name: Build Docker image + # DATABASE_URL 仅在 prisma generate 阶段需要, 可用任何合法占位串 + # 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 + run: docker images + + - name: Apply DB migrations + # 数据库自动升级: 按 prisma/migrations/ 下的迁移顺序应用 (生产库由 master 部署时统一升级) + # migrate deploy 幂等, 无新迁移时为 no-op + # 位置关键: 在 "Remove old container" 之前 —— 建表/改表成功后才切换流量, + # 迁移失败则整个 workflow 中断, 不会用新镜像顶掉仍在运行的旧容器 + env: + DATABASE_URL: ${{ secrets.DATABASE_URL }} + run: | + set -euo pipefail + npx prisma migrate status || true + npx prisma migrate deploy + + - name: Remove old container + run: docker rm -vf iboard || true + + - name: Prune dangling images + # 只清理本项目的悬空镜像 (旧 iboard:latest 被同名新镜像顶掉后变 dangling), + # 不再用 `docker system prune --volumes` —— 那会清光宿主机 build cache, + # 导致每次部署都全量重装 npm 依赖、重跑构建 + run: docker image prune -f + + - name: Run container + # DATABASE_URL 通过 gitea Actions secret 注入, 不进镜像层 + # OTA_PACKAGE_DIR 已在 Dockerfile 设为 /app/ota-package + # 米家凭证 (auth.txt) 不进 git: 运维放宿主机 /app/iboard/cache, bind-mount 进容器 (MIHOME_CACHE_DIR=/app/cache) + run: | + docker run -d --name iboard -p 3001:3001 \ + -e DATABASE_URL="${{ secrets.DATABASE_URL }}" \ + -e JWT_SECRET="${{ secrets.JWT_SECRET }}" \ + -e MIHOME_CACHE_DIR=/app/cache \ + -v /app/iboard/cache:/app/cache \ + iboard:latest + env: + DATABASE_URL: ${{ secrets.DATABASE_URL }} + JWT_SECRET: ${{ secrets.JWT_SECRET }} diff --git a/.gitea/workflows/ota-build.yml b/.gitea/workflows/ota-build.yml index 40538a0..6bcc46f 100644 --- a/.gitea/workflows/ota-build.yml +++ b/.gitea/workflows/ota-build.yml @@ -1,75 +1,97 @@ -name: OTA Frontend Build & Sign - -# 前端 OTA 升级包构建 + 签名 + 上传 artifact -# 触发: 仅手动 (workflow_dispatch), 发版前用 -# - 主分支 push 的自动 OTA 生成已并入 docker-build.yml (随镜像发布), 本 workflow 不再监听 push 避免重复构建 -# 输入: sequence / notes / min_binary (可覆盖默认) -# Secret 依赖: MINISIGN_PRIVATE_KEY (整段 .key 文件内容, 含首行 untrusted comment) -# 注意: 此 workflow 不会修改 src-tauri/tauri.conf.json 里的 pubkey, -# 换密钥请手动改 conf 并 commit. - -on: - workflow_dispatch: - inputs: - sequence: - description: 'OTA sequence (单调递增整数, 留空用 timestamp)' - required: false - type: string - notes: - description: '发布说明 (写入 latest.json.notes)' - required: false - type: string - default: '' - min_binary: - description: '最低 binary 版本 (默认 0.1.0 = 现有 APK 的原生版本, 与前端应用版本无关)' - required: false - type: string - default: '0.1.0' - -jobs: - build-ota: - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: https://gitea.com/actions/checkout@v4 - - - name: Setup Node - uses: https://gitea.com/actions/setup-node@v4 - with: - node-version: 22 - - - name: Install deps - run: npm ci --no-audit --no-fund - - - name: Build frontend - # 复用 vite build 产物; 不打 Docker (本 workflow 只关心前端) - run: npm run build - - - name: Install minisign - run: sudo apt-get update && sudo apt-get install -y minisign - - - name: Build & sign OTA package - env: - MINISIGN_PRIVATE_KEY: ${{ secrets.MINISIGN_PRIVATE_KEY }} - OTA_NOTES: ${{ inputs.notes }} - run: | - set -euo pipefail - OUT="ota-package" - ARGS="--out ${OUT}" - # sequence 留空 → 脚本默认用时间戳; min_binary 默认 0.1.0 (与前端版本解耦) - if [ -n "${{ inputs.sequence }}" ]; then ARGS="${ARGS} --sequence ${{ inputs.sequence }}"; fi - if [ -n "${OTA_NOTES}" ]; then ARGS="${ARGS} --notes ${OTA_NOTES}"; fi - if [ -n "${{ inputs.min_binary }}" ]; then ARGS="${ARGS} --min-binary ${{ inputs.min_binary }}"; fi - node scripts/build-ota.mjs ${ARGS} - echo "===== latest.json =====" - cat ${OUT}/latest.json - echo "===== bundle sha256 =====" - sha256sum ${OUT}/iboard-ota.tar.gz - - - name: Upload OTA package as artifact - uses: https://gitea.com/actions/upload-artifact@v4 - with: - name: iboard-ota-${{ inputs.sequence || github.run_number }} - path: ota-package/ - retention-days: 30 - if-no-files-found: error +name: OTA Frontend Build & Sign + +# 前端 OTA 升级包构建 + 签名 + 上传 artifact +# 触发: 仅手动 (workflow_dispatch), 发版前用 +# - 主分支 push 的自动 OTA 生成已并入 docker-build.yml (随镜像发布), 本 workflow 不再监听 push 避免重复构建 +# 输入: sequence / notes / min_binary (可覆盖默认) +# Secret 依赖: MINISIGN_PRIVATE_KEY (整段 .key 文件内容, 含首行 untrusted comment) +# 注意: 此 workflow 不会修改 src-tauri/tauri.conf.json 里的 pubkey, +# 换密钥请手动改 conf 并 commit. + +on: + workflow_dispatch: + inputs: + sequence: + description: 'OTA sequence (单调递增整数, 留空用 timestamp)' + required: false + type: string + notes: + description: '发布说明 (写入 latest.json.notes)' + required: false + type: string + default: '' + min_binary: + description: '最低 binary 版本 (默认 0.1.0 = 现有 APK 的原生版本, 与前端应用版本无关)' + required: false + type: string + default: '0.1.0' + +jobs: + build-ota: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: https://gitea.com/actions/checkout@v4 + + - name: Setup Node + uses: https://gitea.com/actions/setup-node@v4 + with: + node-version: 22 + + - name: Install deps + run: npm ci --no-audit --no-fund + + - name: Build frontend + # 复用 vite build 产物; 不打 Docker (本 workflow 只关心前端) + run: npm run build + + - name: Install 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 + # 发布说明默认 = 检出 commit 的 message (标题+正文), commit sha 单独写入; + # 手动填写的 notes 优先 + env: + MINISIGN_PRIVATE_KEY: ${{ secrets.MINISIGN_PRIVATE_KEY }} + run: | + set -euo pipefail + OUT="ota-package" + ARGS="--out ${OUT} --commit ${GITHUB_SHA}" + if [ -n "${{ inputs.sequence }}" ]; then ARGS="${ARGS} --sequence ${{ inputs.sequence }}"; fi + if [ -n "${{ inputs.notes }}" ]; then + ARGS="${ARGS} --notes \"${{ inputs.notes }}\"" + else + MSG="$(git log -1 --pretty=format:%B | sed -e '/^[[:space:]]*$/d')" + ARGS="${ARGS} --notes \"${MSG}\"" + fi + if [ -n "${{ inputs.min_binary }}" ]; then ARGS="${ARGS} --min-binary ${{ inputs.min_binary }}"; fi + eval "node scripts/build-ota.mjs ${ARGS}" + echo "===== latest.json =====" + cat ${OUT}/latest.json + echo "===== bundle sha256 =====" + sha256sum ${OUT}/iboard-ota.tar.gz + + - name: Upload OTA package as artifact + uses: https://gitea.com/actions/upload-artifact@v4 + with: + name: iboard-ota-${{ inputs.sequence || github.run_number }} + path: ota-package/ + retention-days: 30 + if-no-files-found: error diff --git a/.local/cdp-drag-test.mjs b/.local/cdp-drag-test.mjs new file mode 100644 index 0000000..a545c8d --- /dev/null +++ b/.local/cdp-drag-test.mjs @@ -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() diff --git a/.local/cdp-drag-test2.mjs b/.local/cdp-drag-test2.mjs new file mode 100644 index 0000000..e5630c8 --- /dev/null +++ b/.local/cdp-drag-test2.mjs @@ -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() diff --git a/.local/cdp-lp.mjs b/.local/cdp-lp.mjs new file mode 100644 index 0000000..ba33313 --- /dev/null +++ b/.local/cdp-lp.mjs @@ -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() diff --git a/.local/cdp-reorder.mjs b/.local/cdp-reorder.mjs new file mode 100644 index 0000000..eb8ef21 --- /dev/null +++ b/.local/cdp-reorder.mjs @@ -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() diff --git a/.local/cdp-scan.mjs b/.local/cdp-scan.mjs new file mode 100644 index 0000000..bf0ac2f --- /dev/null +++ b/.local/cdp-scan.mjs @@ -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() diff --git a/.local/cdp-shot.mjs b/.local/cdp-shot.mjs new file mode 100644 index 0000000..31b02c7 --- /dev/null +++ b/.local/cdp-shot.mjs @@ -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() diff --git a/.local/cdp-task-grip.mjs b/.local/cdp-task-grip.mjs new file mode 100644 index 0000000..8f516c2 --- /dev/null +++ b/.local/cdp-task-grip.mjs @@ -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() diff --git a/.local/cdp-verify.mjs b/.local/cdp-verify.mjs new file mode 100644 index 0000000..18699ce --- /dev/null +++ b/.local/cdp-verify.mjs @@ -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() diff --git a/.local/msg.txt b/.local/msg.txt new file mode 100644 index 0000000..3833c95 --- /dev/null +++ b/.local/msg.txt @@ -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 实测拖动中容器背景已透明、宽度正常。 diff --git a/.local/re-test.mjs b/.local/re-test.mjs new file mode 100644 index 0000000..95e97c5 --- /dev/null +++ b/.local/re-test.mjs @@ -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, ''))) diff --git a/.local/tmp/system-commandline-sentinel-files/dotnet-suggest-registration-git-credential-manager, Version=2.9.0.0, Culture=neutral, PublicKeyToken=null b/.local/tmp/system-commandline-sentinel-files/dotnet-suggest-registration-git-credential-manager, Version=2.9.0.0, Culture=neutral, PublicKeyToken=null new file mode 100644 index 0000000..4db2c93 --- /dev/null +++ b/.local/tmp/system-commandline-sentinel-files/dotnet-suggest-registration-git-credential-manager, Version=2.9.0.0, Culture=neutral, PublicKeyToken=null @@ -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.<b__5_1>d.MoveNext() \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index e250f80..0137421 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,14 +1,31 @@ # CHANGELOG -版本号规则见 [README.md](README.md)「版本管理」。**PATCH (第三位) 只在明确提出时自增, 否则一直沿用**; -每次自增在本文件追加一行。MINOR 对应开发分支 (V0.2 ↔ dev-0.2), MAJOR 对应大迭代。 +版本号规则见 [README.md](README.md)「版本管理」。前端与外壳版本号**互相独立**、独立发版, +分三部分记录;PATCH 仅在明确提出时自增, 否则沿用, 每次变更在对应部分追加一行。 -## 0.2.0 +--- -- 2026-10-25 起: V0.2 基线。前端与外壳 (Android 0.2.0) 版本号解绑, 外壳独立维护; - OTA 版本 = 前端版本 + `-ota`; build 标识统一 `YYYYMMDDHHmmss` (北京时间)。 -- 本条为基线记录, PATCH 仍为 0。 +## 程序版本 (Web / OTA) -## 0.2.1 +**当前值: 0.2.0** (管理点: `package.json` 的 `version`; OTA 包 = 同值 + `-ota`) -- 2026-10-25 起: 本版本主要解决离线编辑与不同终端数据合并问题。 +| 日期 | 版本 | 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 | 要点 | +| --- | --- | --- | --- | +| — | — | — | 暂无 | diff --git a/Dockerfile b/Dockerfile index 7b4405c..ffec25c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,73 +1,85 @@ -# =============================================================== -# iBoard 一体化镜像: 静态前端 (dist/) + Express API (server/) 同进程 -# 构建: docker build --build-arg DATABASE_URL=... -t iboard:latest . -# 可选 --build-arg VITE_API_BASE=https://api.example.com 让前端走绝对地址 -# 运行: docker run -d --name iboard -p 3001:3001 -e DATABASE_URL=... iboard:latest -# =============================================================== -FROM node:22-alpine AS builder - -WORKDIR /app - -# 国内构建时可换源 -RUN npm config set registry https://registry.npmmirror.com - -# 先拷贝 package 文件, 利用 Docker 缓存 -COPY package*.json ./ -COPY prisma ./prisma/ -COPY prisma.config.ts ./ -COPY generated ./generated/ - -# 装全量依赖 (dev 也装, 后面 build 要用) -RUN npm install - -# 拷贝源码 -COPY . . - -# 构建前端 (输出到 dist/) 与 Prisma 客户端 -RUN npx prisma generate -# build 时把 VITE_API_BASE 写进 bundle, 默认空 = 走相对路径 /api (同源 Express) -ARG VITE_API_BASE= -ENV VITE_API_BASE=$VITE_API_BASE -RUN npm run build - -# 运行时阶段 -FROM node:22-alpine - -WORKDIR /app - -# 阿里源加速 -RUN npm config set registry https://registry.npmmirror.com - -# 只装运行时依赖 -COPY package*.json ./ -RUN npm install --omit=dev - -# 拷贝构建产物 -COPY --from=builder /app/dist ./dist -COPY --from=builder /app/server ./server -COPY --from=builder /app/generated ./generated -COPY --from=builder /app/prisma ./prisma -COPY --from=builder /app/prisma.config.ts ./prisma.config.ts -COPY --from=builder /app/scripts ./scripts -COPY --from=builder /app/content ./content -COPY --from=builder /app/public ./public - -# OTA 升级包: CI (docker-build.yml) 生成的 ota-package/, 随镜像发布 -# 本地构建时只有占位文件 (ota-package/README.md), server 找不到 latest.json 则 OTA 返回"无更新" -COPY ota-package ./ota-package -ENV OTA_PACKAGE_DIR=/app/ota-package -# OTA bundle 下载用的绝对 base (manifest.url 必须是绝对 URL, 插件不拼 base, 相对路径会导致下载 0% 失败) -# 生产为 http 明文 (无 https), 客户端 tauri.conf.json 的 hotswap.require_https 已设为 false 放行 http -ENV OTA_PUBLIC_BASE=http://bh.vps.honor3.com:3001 - -# 健康检查 -HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ - CMD wget -qO- http://127.0.0.1:3001/api/health || exit 1 - -EXPOSE 3001 - -ENV NODE_ENV=production -ENV PORT=3001 - -# Node 22 默认未开 strip-types, 显式开启以便加载 generated/prisma/*.ts +# syntax=docker/dockerfile:1 +# =============================================================== +# iBoard 一体化镜像: 静态前端 (dist/) + Express API (server/) 同进程 +# 构建: docker build --build-arg DATABASE_URL=... -t iboard:latest . +# 可选 --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 +# 需要 BuildKit (cache mount 语法; Docker 23+ 默认开启) +# =============================================================== +FROM node:22-alpine AS builder + +WORKDIR /app + +# 国内构建时可换源 +RUN npm config set registry https://registry.npmmirror.com + +# 先拷贝 package 文件, 利用 Docker 缓存 +COPY package*.json ./ +COPY prisma ./prisma/ +COPY prisma.config.ts ./ +COPY generated ./generated/ + +# 装全量依赖 (dev 也装, 后面 prisma generate / 本地构建前端要用) +# cache mount 复用 npm 下载缓存, 不进镜像层 +RUN --mount=type=cache,target=/root/.npm npm ci + +# 拷贝源码 +COPY . . + +# 构建 Prisma 客户端 +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) +ARG VITE_API_BASE= +ENV VITE_API_BASE=$VITE_API_BASE +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 + +WORKDIR /app + +# 阿里源加速 +RUN npm config set registry https://registry.npmmirror.com + +# 只装运行时依赖 +COPY package*.json ./ +RUN --mount=type=cache,target=/root/.npm npm ci --omit=dev + +# 拷贝构建产物 +COPY --from=builder /app/dist ./dist +COPY --from=builder /app/server ./server +COPY --from=builder /app/generated ./generated +COPY --from=builder /app/prisma ./prisma +COPY --from=builder /app/prisma.config.ts ./prisma.config.ts +COPY --from=builder /app/scripts ./scripts +COPY --from=builder /app/content ./content +COPY --from=builder /app/public ./public + +# OTA 升级包: CI (docker-build.yml) 生成的 ota-package/, 随镜像发布 +# 本地构建时只有占位文件 (ota-package/README.md), server 找不到 latest.json 则 OTA 返回"无更新" +COPY ota-package ./ota-package +ENV OTA_PACKAGE_DIR=/app/ota-package +# OTA bundle 下载用的绝对 base (manifest.url 必须是绝对 URL, 插件不拼 base, 相对路径会导致下载 0% 失败) +# 生产为 http 明文 (无 https), 客户端 tauri.conf.json 的 hotswap.require_https 已设为 false 放行 http +ENV OTA_PUBLIC_BASE=http://bh.vps.honor3.com:3001 + +# 健康检查 +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD wget -qO- http://127.0.0.1:3001/api/health || exit 1 + +EXPOSE 3001 + +ENV NODE_ENV=production +ENV PORT=3001 + +# Node 22 默认未开 strip-types, 显式开启以便加载 generated/prisma/*.ts CMD ["node", "--experimental-strip-types", "--no-warnings", "server/index.js"] \ No newline at end of file diff --git a/readme b/README.md similarity index 60% rename from readme rename to README.md index 2716367..46e30da 100644 --- a/readme +++ b/README.md @@ -1,5 +1,28 @@ # iBoard -用于收集和整理经济数据,但是以策略为主体重新组织。 + +跨平台个人看板 (Tauri + React + Express + MariaDB)。用于收集和整理经济数据,但是以策略为主体重新组织。 + +- 首页主要是放经济文章 +- 数据主要是放各种经济数据和图表 +- 策略是放按照经济理论组织的相关数据和图表的对比 + +## 首页 + +首页显示新闻模块和报警模块的数据,按照时间最新优先,无限滚动,直到数据库中没有数据为止。 + +### 新闻模块 + +增加一个新闻模块,数据来源是每日夜间 3 点,通过妙想 MCP 获取国内外最新经济政治新闻,按影响大小排序 10 条。存入数据库。 + +### 报警模块 + +World Bank 中的数据只用来对比妙想 MCP 拉取的数据正确性。当同一时间的同一项数据不同时,记录一个报警。 + +## 监控 + +监控分为服务器监控和服务监控两部分。服务器监控对接 komari,地址: + +--- ## 分支规则 @@ -10,7 +33,7 @@ | `master` | **主分支**(生产分支) | ✅ 唯一部署出口,push 即发布生产 | — | | `dev-*` | **开发集成分支**,如 `dev-0.2` | ❌ 不部署(只跑 CI 校验) | 合到 `master` 后由 master 部署 | | `tmp-*` | **临时分支**,针对单个问题的修改 | ❌ 不部署 | 合到对应 `dev-*` 后删除 | -| `android-*` | **Android 外壳构建分支**,如 `android-1.0` | ❌ 不部署,只触发 APK 构建 | 构建前须与 `master` 同步 | +| `android-*` | **Android 外壳构建分支** | ❌ 不部署,只触发 APK 构建 | 构建前须与 `master` 同步 | > **关于 `main`**:团队约定里的「main 主分支」在本仓库落地为 **`master`**(仓库默认分支即 `master`,不另建 `main`)。 > 下文所有「main 主分支」均指 `master`。 @@ -45,35 +68,48 @@ android-1.0 ── 从 master 同步代码后 push ──▶ 只触发 APK 构 > 此前 `dev-*` 与 `master` 共用同一套生产环境,推送开发分支等于直接发布,容易把半成品带上线。 > 现在开发分支的提交只有合并进 `master` 并推送后才会部署。 +--- + ## 版本管理 -### 一句话规则 - -**同一个版本号,APK(原生包)裸写,OTA 热更包多一个 `-ota` 后缀。** +### 版本号构成与口径(2026-10-25 决议) ``` -分支 dev-0.2 → 版本 0.2.0 - ├─ APK / 桌面安装包 versionName = 0.2.0 (无后缀) - ├─ 前端运行时 APP_VERSION = 0.2.0 (无后缀) - └─ OTA 热更包 version = 0.2.0-ota (多一个 -ota) +V MAJOR.MINOR.PATCH 例: V0.2.0 + │ └── PATCH 小版本号: 仅在明确提出时自增, 否则沿用 (记录见 CHANGELOG.md) + └── MINOR 大版本迭代, 对应一个开发分支 (V0.2 ↔ dev-0.2) ``` -版本号遵循 `主版本.次版本.修订号`,**唯一手动管理点是 `package.json` 的 `version` 字段**(当前 `0.2.0`), -该字段只写裸版本号,**不允许带 `-ota`**——`-ota` 由 `scripts/build-ota.mjs` 打包时自动追加。 +**前端与外壳解绑,独立维护、独立发版**(2026-10-25 决议): -版本号必须与所在分支对应:`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-.` 分支前缀对应。 +- 分支口径:**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 打包脚本均读取此值 | -| `src-tauri/tauri.conf.json` | `version` | Tauri 应用版本 → **APK 的 versionName**、桌面安装包版本 | -| `src-tauri/Cargo.toml` | `[package].version` | Rust 包版本,需与 tauri.conf.json 一致 | -| `src-tauri/Cargo.lock` | `name = "iboard"` 条目 | 同上,随 Cargo.toml 一起改 | - -`scripts/ci/check-version.mjs` 会在 CI 第一段就校验这四处**完全相等**且不含 `-ota`, -并校验 `dev-.` 分支与版本前缀对应;任何一项不满足直接构建失败(`main`/`master` 只跳过分支校验,一致性照样查)。 +| 组 | 文件 | 字段 | 说明 | +| --- | --- | --- | --- | +| 前端 | `package.json` | `version` | **前端唯一源**,`src/version.js` 与 OTA 打包脚本均读取此值 | +| 外壳 | `src-tauri/tauri.conf.json` | `version` | APK 的 versionName、桌面安装包版本 | +| 外壳 | `src-tauri/Cargo.toml` | `[package].version` | Rust 包版本,须与 tauri.conf.json 一致 | +| 外壳 | `src-tauri/Cargo.lock` | `name = "iboard"` 条目 | 随 Cargo.toml 一起改 | ### 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` 缓存没刷新, 删掉该目录重新 `npx tauri android init` 即可。 ### 其它 -- `src/version.js` 从 `package.json` 取值,前端"我"页与 OTA 检查接口展示的即此版本,不要另写死版本号。 -- OTA 升级包的 `version` = `-ota`(见 `scripts/build-ota.mjs`), - 所以版本号错位会直接导致线上 OTA 判新失准。 +- `src/version.js` 从 `package.json` 取值,前端运行时与 OTA 检查接口展示的即此版本,不要另写死版本号。 - **`min_binary_version` 与这里的版本号是两件事**:它是 OTA 包要求的最低原生二进制版本 (CI 里由 `OTA_MIN_BINARY` 显式指定,当前 `0.1.0`)。前端版本升到 0.2.0 时它不应跟着抬, 否则装了 0.1.0 APK 的设备会直接收不到热更。 -- `BUILD_ID` 每次编译自动生成(见 `vite.config.js` 的 `__BUILD_ID__`),无需手动维护。 - `ota-package/latest.json` 为运行时产物,不纳入版本管理。 -## 分支与 CI/CD (Gitea Actions) - -分支按版本命名:`dev-0.1`、`dev-0.2`…… 对应 `docs/V<版本>/` 下的需求/设计文档。 -职责与合并方向见上文「分支规则」。 - -| 分支 | 触发的 workflow | 行为 | -| --- | --- | --- | -| `master`(主分支) | `docker-build.yml` | 生产发布:构建前端 → 生成签名 OTA 包 → 打 Docker 镜像 → 数据库自动升级 → 重启容器 `iboard` (宿主 3001) | -| `dev-*` | **不触发部署** | 推送只走版本一致性校验;上线须先合并到 `master` | -| `tmp-*` | 不触发 | 临时分支,合到 `dev-*` 后删除 | -| `android-*` | `android-build.yml` | 打签名 release APK 并上传到 Gitea Generic Package Registry(构建前须与 `master` 同步) | -| 手动 | `ota-build.yml` / `sync-registry.yml` | 单独出 OTA 包 / 同步 secrets 到注册中心 | - -数据库升级约定: -- 唯一入口是 `prisma/migrations/` 下的迁移文件,由 `npx prisma migrate deploy` 在**启新容器之前**执行 - (幂等,无新迁移时为 no-op);迁移失败会中断 workflow,不会用新镜像顶掉仍在运行的旧容器。 -- 由于只有 `master` 会部署,**迁移真正作用到生产库的唯一时机就是 master 发布**; - 开发分支上的迁移不会被自动应用,请在合并到 `master` 前确认可回滚。 -- 本地改了 `prisma/schema.prisma` 后:写迁移 SQL → `npx prisma migrate deploy` 验证 → 提交迁移目录。 - -- 首页主要是放经济文章 -- 数据主要是放各种经济数据和图表。 -- 策略是放按照经济理论组织的相关数据和图表的对比。 - - - -## 首页 -首页显示新闻模块和报警模块的数据,按照时间最新优先,无限滚动,直到数据库中没有数据为止。 - - -### 新闻模块 -增加一个新闻模块,数据来源是每日夜间 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 升级 -只升级前端资源 (`dist/`), **不触碰** Tauri 框架/原生层, 不重打 APK. +只升级前端资源 (`dist/`),**不触碰** Tauri 框架/原生层,不重打 APK。 底层使用 [`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 优先命中覆盖包 ``` +### 发布说明与提交号(2026-10-25 起) + +- CI(docker-build.yml)把**触发构建的 commit message**(标题+正文,去空行,截断 400 字)作为 `notes` 写入 latest.json; +- commit sha 经 `--commit` 参数单独写入 `commit` 字段; +- "我的"页:发布说明展示 commit message,「提交」行展示短 sha。 + ### 首次部署 1. **生成签名密钥** (一次性): @@ -178,8 +186,7 @@ scripts/build-ota.mjs │ 生成 iboard-ota.tar.gz + minisign 签名 node scripts/ota-genkey.mjs # 产物: 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`): ```json "hotswap": { @@ -188,7 +195,7 @@ scripts/build-ota.mjs │ 生成 iboard-ota.tar.gz + minisign 签名 "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/ node scripts/build-ota.mjs # 打成 tar.gz + 签名 + latest.json # sequence 默认 = Date.now(), 也可 --sequence 43 手动指定 -# 也可加 --notes "修复登录按钮" --min-binary 0.1.0 +# 也可加 --notes "修复登录按钮" --commit --min-binary 0.1.0 ``` 然后把 `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 不生效**. -> OTA 仅在 release/打包构建中可用, 测试请用 `tauri build` 产物. +> **注意:** Tauri 2 的 dev 模式 (`tauri dev`) 会代理到 Vite dev server,**OTA 不生效**。 +> OTA 仅在 release/打包构建中可用,测试请用 `tauri build` 产物。 -### 客户端判新口径(V0.2 新增) +### 客户端判新口径(V0.2) "我"页 OTA 组内有一个 **开发版本** 开关(默认关闭),切换"怎么算新版本": @@ -226,25 +233,31 @@ node scripts/build-ota.mjs # 打成 tar.gz + 签名 + latest.json ### Web 端 -Web 浏览器访问 (同源) 时, 服务端返回简化版 manifest, 仅显示"有新版本请刷新". -Web 端不会自动下载包, 也不会替换前端代码. - +Web 浏览器访问 (同源) 时,服务端返回简化版 manifest,仅显示"有新版本请刷新"。 +Web 端不会自动下载包,也不会替换前端代码。 ### CI 自动签名 (Gitea Actions) -.gitea/workflows/ota-build.yml 是手动触发的 OTA 打包工作流 (每次发版前点一次). +.gitea/workflows/ota-build.yml 是手动触发的 OTA 打包工作流(每次发版前点一次)。 **配置 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 -2. 填 sequence (可选, 不填用时间戳) 和 -otes (可选) +2. 填 sequence(可选,不填用时间戳)和 notes(可选) 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. 客户端"我"页 [检查更新] 即可触发升级 **注意**: -- CI 用的私钥必须**与** auri.conf.json 里 plugins.hotswap.pubkey **配对**; 换密钥时需要同时更新 CI secret 和 conf. -- 不要把私钥 commit 到 git, 整个 ota-package/hotswap.key 已被 .gitignore 排除. +- CI 用的私钥必须**与** `tauri.conf.json` 里 plugins.hotswap.pubkey **配对**;换密钥时需要同时更新 CI secret 和 conf。 +- 不要把私钥 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` 验证 → 提交迁移目录。 diff --git a/docs/V0.2/任务模块/任务模块功能设计.md b/docs/V0.2/任务模块/任务模块功能设计.md index 69fd4e8..868c1e2 100644 --- a/docs/V0.2/任务模块/任务模块功能设计.md +++ b/docs/V0.2/任务模块/任务模块功能设计.md @@ -155,6 +155,9 @@ | T-TK-11 | `TaskMember` 关联表 | 多对多,`@@unique([taskId,userId])` 去重 | | T-TK-12 | `TaskTag` 表 + 卡片标签渲染 | 多标签,展示于卡片/条目 | | 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` | --- diff --git a/docs/V0.2/任务模块/任务模块数据库设计文档.md b/docs/V0.2/任务模块/任务模块数据库设计文档.md index 02acd7e..e7e138e 100644 --- a/docs/V0.2/任务模块/任务模块数据库设计文档.md +++ b/docs/V0.2/任务模块/任务模块数据库设计文档.md @@ -176,8 +176,10 @@ | `projectId` | `INT` | 否 | — | FK → `Project.id` `ON DELETE CASCADE` | | `columnId` | `INT` | 否 | — | FK → `BoardColumn.id` `ON DELETE RESTRICT`(T-BD-01) | | `parentId` | `INT` | **是** | `NULL` | 非空 = 一级子任务;FK → `Task.id` `ON DELETE CASCADE`(T-ST-01/11) | -| `title` | `VARCHAR(200)` | 否 | — | 唯一必填(T-TK-02) | -| `description` | `TEXT` | 否 | `''` | — | +| `title` | `VARCHAR(200)` | 否 | — | 唯一必填(T-TK-02);应用层限 30 字(T-TK-14) | +| `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) | | `doneAt` | `DATETIME(3)` | **是** | `NULL` | 完成时刻;**保留选项判定依据**(T-FL-10) | | `assigneeId` | `INT` | **是** | `NULL` | 负责人(单人);新建默认创建者(T-TK-09) | diff --git a/docs/V0.2/任务模块/任务模块需求文档.md b/docs/V0.2/任务模块/任务模块需求文档.md index 7d9ffe9..8ba1ee5 100644 --- a/docs/V0.2/任务模块/任务模块需求文档.md +++ b/docs/V0.2/任务模块/任务模块需求文档.md @@ -240,6 +240,9 @@ | T-TK-11 | 每条任务有**任务成员(多人)** | 可设置 0 ~ 多个成员,成员不重复 | P0 | | T-TK-12 | 任务支持**标签** | 可添加多个标签,展示于条目 / 卡片 | 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) diff --git a/docs/V0.2/离线编辑模块/PowerSync可行性研究报告.md b/docs/V0.2/离线编辑模块/PowerSync可行性研究报告.md new file mode 100644 index 0000000..17c4754 --- /dev/null +++ b/docs/V0.2/离线编辑模块/PowerSync可行性研究报告.md @@ -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` diff --git a/index.html b/index.html index fbabbb2..9d8210e 100644 --- a/index.html +++ b/index.html @@ -3,7 +3,7 @@ - + iBoard - 经济数据与策略 diff --git a/prisma/migrations/20261025030000_task_kind/migration.sql b/prisma/migrations/20261025030000_task_kind/migration.sql new file mode 100644 index 0000000..4527e01 --- /dev/null +++ b/prisma/migrations/20261025030000_task_kind/migration.sql @@ -0,0 +1,3 @@ +-- 任务类型标签: 项目级自定义列表 + 任务上的类型 +ALTER TABLE `Project` ADD COLUMN `taskKinds` JSON NULL; +ALTER TABLE `Task` ADD COLUMN `kind` VARCHAR(20) NULL; diff --git a/prisma/migrations/20261025120000_task_mark_date/migration.sql b/prisma/migrations/20261025120000_task_mark_date/migration.sql new file mode 100644 index 0000000..2581ee0 --- /dev/null +++ b/prisma/migrations/20261025120000_task_mark_date/migration.sql @@ -0,0 +1,2 @@ +-- 任务时间标记: 完整日期存储, 前端展示 MM-dd +ALTER TABLE `Task` ADD COLUMN `markDate` DATETIME(3) NULL; diff --git a/prisma/migrations/20261025130000_project_icon/migration.sql b/prisma/migrations/20261025130000_project_icon/migration.sql new file mode 100644 index 0000000..927fb23 --- /dev/null +++ b/prisma/migrations/20261025130000_project_icon/migration.sql @@ -0,0 +1,2 @@ +-- 项目图标: data URL (客户端裁剪 96x96 JPEG, 约 10-20KB) +ALTER TABLE `Project` ADD COLUMN `icon` TEXT NULL; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 28cfc7c..5a0fe01 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -394,6 +394,8 @@ model Project { isPersonal Boolean @default(false) archived Boolean @default(false) notes Json @default("[]") // 项目信息块 [{ title, content }] (T-PJ-13) + taskKinds Json? // 任务类型标签列表 (未配置时服务端回落默认四类) + icon String? @db.Text // 项目图标 (data URL, 客户端裁剪为 96x96 JPEG) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -447,6 +449,8 @@ model Task { title String @db.VarChar(200) description String @default("") @db.Text + kind String? @db.VarChar(20) // 任务类型标签 (项目 taskKinds 之一) + markDate DateTime? // 时间标记 (完整日期存储, 展示为 MM-dd) status String @default("todo") @db.VarChar(8) doneAt DateTime? diff --git a/scripts/build-ota.mjs b/scripts/build-ota.mjs index 15fd6f9..3eab17e 100644 --- a/scripts/build-ota.mjs +++ b/scripts/build-ota.mjs @@ -1,147 +1,157 @@ -#!/usr/bin/env node -// OTA 升级包生成脚本 (兼容 tauri-plugin-hotswap) -// 用法: -// node scripts/build-ota.mjs [--out ] [--sequence ] [--min-binary ] [--notes ] [--no-sign] [--key ] -// 流程: -// 1) 校验 dist/ 已构建 -// 2) 生成 bundle.tar.gz (dist/ 内容) -// 3) minisign 签名 (除非 --no-sign; 私钥优先读 MINISIGN_PRIVATE_KEY 环境变量) -// 4) 写 latest.json (manifest, 含 sequence/version/signature/bundle_size/notes/...) -// 5) 全部产物输出到 out 目录 (默认 ota-package/) -// -// minisign 安装: 见 readme "OTA 升级" 章节 -import fs from 'node:fs' -import path from 'node:path' -import crypto from 'node:crypto' -import { fileURLToPath } from 'node:url' -import { spawnSync } from 'node:child_process' - -const __filename = fileURLToPath(import.meta.url) -const __dirname = path.dirname(__filename) -const ROOT = path.resolve(__dirname, '..') - -const args = process.argv.slice(2) -function getArg(name, def) { - const i = args.indexOf(name) - return i >= 0 && args[i + 1] ? args[i + 1] : def -} -const OUT_DIR = path.resolve(getArg('--out', 'ota-package')) -const SEQUENCE = Number(getArg('--sequence', String(Date.now()))) // 默认用时间戳做单调计数 -const MIN_BINARY = getArg('--min-binary', '') -const NOTES = getArg('--notes', '') -const NO_SIGN = args.includes('--no-sign') -// 私钥优先来源: MINISIGN_PRIVATE_KEY 环境变量 (CI 注入) → 文件 (--key 路径) -const KEY_PATH = path.resolve(getArg('--key', path.join(OUT_DIR, 'hotswap.key'))) -const KEY_FROM_ENV = !!process.env.MINISIGN_PRIVATE_KEY -// 旧版 tauri.conf.json 里的 pubkey (自动同步过去, 方便用户复制) -const TAURI_CONF = path.join(ROOT, 'src-tauri', 'tauri.conf.json') - -const pkg = JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8')) -const APP_VERSION = pkg.version || '0.0.0' - -const DIST = path.join(ROOT, 'dist') -if (!fs.existsSync(path.join(DIST, 'index.html'))) { - console.error('[build-ota] 错误: dist/index.html 不存在, 请先执行 npm run build') - process.exit(1) -} - -// 准备输出目录 -fs.mkdirSync(OUT_DIR, { recursive: true }) - -// 1) 生成 bundle.tar.gz -const BUNDLE_NAME = 'iboard-ota.tar.gz' -const BUNDLE_PATH = path.join(OUT_DIR, BUNDLE_NAME) -const r = spawnSync('tar', ['-czf', BUNDLE_PATH, '-C', DIST, '.'], { stdio: 'inherit' }) -if (r.status !== 0) { - console.error('[build-ota] tar 打包失败') - process.exit(1) -} -const bundleStat = fs.statSync(BUNDLE_PATH) -const bundleSha256 = crypto.createHash('sha256').update(fs.readFileSync(BUNDLE_PATH)).digest('hex') - -// 2) 签名 (minisign) -let signature = '' -let pubkey = '' -if (!NO_SIGN) { - // CI 注入: 私钥走环境变量, 写到临时文件, 签名后立即删除 (避免落盘) - let KEY_PATH_TMP = null - let KEY_PATH_USE = KEY_PATH - if (KEY_FROM_ENV) { - KEY_PATH_TMP = path.join(OUT_DIR, `.hotswap.key.${process.pid}`) - fs.writeFileSync(KEY_PATH_TMP, process.env.MINISIGN_PRIVATE_KEY, { mode: 0o600 }) - KEY_PATH_USE = KEY_PATH_TMP - } - if (!fs.existsSync(KEY_PATH_USE)) { - console.error('[build-ota] 私钥不存在:', KEY_PATH_USE) - console.error(' 注入方式二选一:') - console.error(' 1) CI: 设环境变量 MINISIGN_PRIVATE_KEY (整段 .key 文件内容, 含首行 untrusted comment)') - console.error(' 2) 本机: 放 ' + KEY_PATH + ', 或用 --key ') - console.error(' 生成方法: minisign -G -W -p ota-package/hotswap.pub -s ota-package/hotswap.key') - process.exit(1) - } - // minisign -Sm -s 生成 detached signature - const sigArgs = ['-Sm', BUNDLE_PATH, '-s', KEY_PATH_USE] - const sigR = spawnSync('minisign', sigArgs, { stdio: 'inherit' }) - // 清理 CI 临时私钥 (无论成功失败都删) - if (KEY_PATH_TMP) { - try { fs.unlinkSync(KEY_PATH_TMP) } catch { /* ignore */ } - KEY_PATH_TMP = null - } - if (sigR.status !== 0) { - console.error('[build-ota] minisign 签名失败') - process.exit(1) - } - const sigPath = BUNDLE_PATH + '.minisig' - signature = fs.readFileSync(sigPath, 'utf8').trim() - - // 尝试同步 pubkey 到 tauri.conf.json (如果存在) - const PUB_PATH = path.join(path.dirname(KEY_PATH), 'hotswap.pub') - if (fs.existsSync(PUB_PATH)) { - pubkey = fs.readFileSync(PUB_PATH, 'utf8').trim() - const lines = pubkey.split(/\r?\n/) - pubkey = (lines.find((l) => l.startsWith('RWR') || l.startsWith('RWT') || l.startsWith('RWQ')) || '').trim() - } -} - -// 3) 写 latest.json -const minBinary = MIN_BINARY || (() => { - // 默认从 src-tauri/tauri.conf.json 读 version - try { return JSON.parse(fs.readFileSync(TAURI_CONF, 'utf8')).version || APP_VERSION } - catch { return APP_VERSION } -})() -const manifest = { - version: APP_VERSION + '-ota', - sequence: SEQUENCE, - min_binary_version: minBinary, - bundle: BUNDLE_NAME, - bundle_size: bundleStat.size, - bundle_sha256: bundleSha256, - signature, - notes: NOTES, - pub_date: new Date().toISOString(), - mandatory: false, - // 内部用: 前端 web 模式比对 build_id - build_id: 'seq-' + SEQUENCE, -} -fs.writeFileSync(path.join(OUT_DIR, 'latest.json'), JSON.stringify(manifest, null, 2)) - -// 4) 提示 -console.log('') -console.log('=== OTA 包生成完成 ===') -console.log('输出目录: ', OUT_DIR) -console.log('版本: ', manifest.version) -console.log('sequence: ', manifest.sequence) -console.log('bundle 大小: ', (bundleStat.size / 1024).toFixed(1) + ' KB') -console.log('bundle sha256:', bundleSha256) -console.log('签名: ', signature ? '(已生成, 写入 latest.json)' : '(未签名, --no-sign)') -if (pubkey) { - console.log('') - console.log('请将下面这一行 pubkey 写到 src-tauri/tauri.conf.json -> plugins.hotswap.pubkey:') - console.log(' ' + pubkey) -} - -console.log('') -console.log('下一步: 把这两个文件发布到 OTA 存储:') -console.log(' ' + BUNDLE_NAME) -console.log(' latest.json') -console.log('并确保 server 进程环境变量 OTA_PACKAGE_DIR 指向该目录。') +#!/usr/bin/env node +// OTA 升级包生成脚本 (兼容 tauri-plugin-hotswap) +// 用法: +// node scripts/build-ota.mjs [--out ] [--sequence ] [--min-binary ] [--notes ] [--no-sign] [--key ] +// 流程: +// 1) 校验 dist/ 已构建 +// 2) 生成 bundle.tar.gz (dist/ 内容) +// 3) minisign 签名 (除非 --no-sign; 私钥优先读 MINISIGN_PRIVATE_KEY 环境变量) +// 4) 写 latest.json (manifest, 含 sequence/version/signature/bundle_size/notes/...) +// 5) 全部产物输出到 out 目录 (默认 ota-package/) +// +// minisign 安装: 见 readme "OTA 升级" 章节 +import fs from 'node:fs' +import path from 'node:path' +import crypto from 'node:crypto' +import { fileURLToPath } from 'node:url' +import { spawnSync } from 'node:child_process' + +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) +const ROOT = path.resolve(__dirname, '..') + +const args = process.argv.slice(2) +function getArg(name, def) { + const i = args.indexOf(name) + return i >= 0 && args[i + 1] ? args[i + 1] : def +} +const OUT_DIR = path.resolve(getArg('--out', 'ota-package')) +const SEQUENCE = Number(getArg('--sequence', String(Date.now()))) // 默认用时间戳做单调计数 +const MIN_BINARY = getArg('--min-binary', '') +const NOTES = getArg('--notes', '') +// 构建对应的 git commit sha (发布说明=commit message, sha 单独一行展示) +const COMMIT = getArg('--commit', '') +const NO_SIGN = args.includes('--no-sign') +// 私钥优先来源: MINISIGN_PRIVATE_KEY 环境变量 (CI 注入) → 文件 (--key 路径) +const KEY_PATH = path.resolve(getArg('--key', path.join(OUT_DIR, 'hotswap.key'))) +const KEY_FROM_ENV = !!process.env.MINISIGN_PRIVATE_KEY +// 旧版 tauri.conf.json 里的 pubkey (自动同步过去, 方便用户复制) +const TAURI_CONF = path.join(ROOT, 'src-tauri', 'tauri.conf.json') + +const pkg = JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8')) +const APP_VERSION = pkg.version || '0.0.0' + +const DIST = path.join(ROOT, 'dist') +if (!fs.existsSync(path.join(DIST, 'index.html'))) { + console.error('[build-ota] 错误: dist/index.html 不存在, 请先执行 npm run build') + process.exit(1) +} + +// 准备输出目录 +fs.mkdirSync(OUT_DIR, { recursive: true }) + +// 1) 生成 bundle.tar.gz +const BUNDLE_NAME = 'iboard-ota.tar.gz' +const BUNDLE_PATH = path.join(OUT_DIR, BUNDLE_NAME) +const r = spawnSync('tar', ['-czf', BUNDLE_PATH, '-C', DIST, '.'], { stdio: 'inherit' }) +if (r.status !== 0) { + console.error('[build-ota] tar 打包失败') + process.exit(1) +} +const bundleStat = fs.statSync(BUNDLE_PATH) +const bundleSha256 = crypto.createHash('sha256').update(fs.readFileSync(BUNDLE_PATH)).digest('hex') + +// 2) 签名 (minisign) +let signature = '' +let pubkey = '' +if (!NO_SIGN) { + // CI 注入: 私钥走环境变量, 写到临时文件, 签名后立即删除 (避免落盘) + let KEY_PATH_TMP = null + let KEY_PATH_USE = KEY_PATH + if (KEY_FROM_ENV) { + KEY_PATH_TMP = path.join(OUT_DIR, `.hotswap.key.${process.pid}`) + fs.writeFileSync(KEY_PATH_TMP, process.env.MINISIGN_PRIVATE_KEY, { mode: 0o600 }) + KEY_PATH_USE = KEY_PATH_TMP + } + if (!fs.existsSync(KEY_PATH_USE)) { + console.error('[build-ota] 私钥不存在:', KEY_PATH_USE) + console.error(' 注入方式二选一:') + console.error(' 1) CI: 设环境变量 MINISIGN_PRIVATE_KEY (整段 .key 文件内容, 含首行 untrusted comment)') + console.error(' 2) 本机: 放 ' + KEY_PATH + ', 或用 --key ') + console.error(' 生成方法: minisign -G -W -p ota-package/hotswap.pub -s ota-package/hotswap.key') + process.exit(1) + } + // minisign -Sm -s 生成 detached signature + const sigArgs = ['-Sm', BUNDLE_PATH, '-s', KEY_PATH_USE] + const sigR = spawnSync('minisign', sigArgs, { stdio: 'inherit' }) + // 清理 CI 临时私钥 (无论成功失败都删) + if (KEY_PATH_TMP) { + try { fs.unlinkSync(KEY_PATH_TMP) } catch { /* ignore */ } + KEY_PATH_TMP = null + } + if (sigR.status !== 0) { + console.error('[build-ota] minisign 签名失败') + process.exit(1) + } + const sigPath = BUNDLE_PATH + '.minisig' + signature = fs.readFileSync(sigPath, 'utf8').trim() + + // 尝试同步 pubkey 到 tauri.conf.json (如果存在) + const PUB_PATH = path.join(path.dirname(KEY_PATH), 'hotswap.pub') + if (fs.existsSync(PUB_PATH)) { + pubkey = fs.readFileSync(PUB_PATH, 'utf8').trim() + const lines = pubkey.split(/\r?\n/) + pubkey = (lines.find((l) => l.startsWith('RWR') || l.startsWith('RWT') || l.startsWith('RWQ')) || '').trim() + } +} + +// 3) 写 latest.json +const minBinary = MIN_BINARY || (() => { + // 默认从 src-tauri/tauri.conf.json 读 version + try { return JSON.parse(fs.readFileSync(TAURI_CONF, 'utf8')).version || APP_VERSION } + catch { return APP_VERSION } +})() +const manifest = { + version: APP_VERSION + '-ota', + sequence: SEQUENCE, + min_binary_version: minBinary, + bundle: BUNDLE_NAME, + bundle_size: bundleStat.size, + bundle_sha256: bundleSha256, + signature, + notes: String(NOTES).slice(0, 400), + commit: COMMIT || undefined, + pub_date: new Date().toISOString(), + mandatory: false, + // 内部用: 前端 web 模式比对 build_id。 + // 格式与前端 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)) + +// 4) 提示 +console.log('') +console.log('=== OTA 包生成完成 ===') +console.log('输出目录: ', OUT_DIR) +console.log('版本: ', manifest.version) +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 sha256:', bundleSha256) +console.log('签名: ', signature ? '(已生成, 写入 latest.json)' : '(未签名, --no-sign)') +if (pubkey) { + console.log('') + console.log('请将下面这一行 pubkey 写到 src-tauri/tauri.conf.json -> plugins.hotswap.pubkey:') + console.log(' ' + pubkey) +} + +console.log('') +console.log('下一步: 把这两个文件发布到 OTA 存储:') +console.log(' ' + BUNDLE_NAME) +console.log(' latest.json') +console.log('并确保 server 进程环境变量 OTA_PACKAGE_DIR 指向该目录。') diff --git a/scripts/ci/check-version.mjs b/scripts/ci/check-version.mjs index 847ee95..0a45cf8 100644 --- a/scripts/ci/check-version.mjs +++ b/scripts/ci/check-version.mjs @@ -1,17 +1,24 @@ #!/usr/bin/env node -// CI 专用: 校验版本号四处一致 + 与分支名一致 +// CI 专用: 校验版本号分组一致 + 前端版本与分支名对应 // -// 背景: -// 版本号只允许一个手工管理点 = package.json 的 version, 其余位置全部派生: -// - APK / 桌面安装包 version = (裸版本号, 无后缀) -// - OTA 热更包 version = -ota (多一个 -ota 标记) -// - 前端运行时 APP_VERSION = -// 任何一处漏改都会让线上版本号、APK 版本、OTA 判新三者对不上, 且很难在客户端发现。 +// 背景 (2026-10-25 调整: 外壳与前端版本号解绑): +// 前端 (Web/OTA) 与外壳 (Android/Windows 壳) 独立维护、独立发版, +// 两组版本号不再要求相等, 只要求**组内**一致: +// +// 组1 前端: package.json .version +// ├─ 前端运行时 APP_VERSION = (裸, 无后缀) +// └─ OTA 热更包 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 -// 并要求 package.json 的 version 不含 "-ota" (裸版本, OTA 后缀由打包脚本追加) -// B. 分支对应 (仅 dev-<版本> 分支): +// A. 组内一致性 (任何分支都查) +// B. 分支对应 (仅 dev-<版本> 分支, 针对前端组): // dev-. → version 必须以 . 开头 // dev- → version 必须以 . 开头 // main / master / 其它 → 跳过 @@ -53,61 +60,59 @@ function readCargoLockVersion() { return null } -const sources = [ - { file: 'package.json', version: String(readJson('package.json').version || '') }, +// 组1 前端 (唯一手动管理点 = package.json) +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/Cargo.toml', version: readCargoTomlVersion() }, { file: 'src-tauri/Cargo.lock (iboard)', version: readCargoLockVersion() }, ] -const version = sources[0].version -const otaVersion = version + '-ota' - console.log('[check-version] 分支: ', branch || '(未知)') -for (const s of sources) { - console.log(`[check-version] ${s.file.padEnd(28)} ${s.version || '(缺失)'}`) -} -console.log('[check-version] 产物版本: APK =', version, '/ OTA =', otaVersion) +for (const s of webSources) 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) let failed = false -// ---- A. 四处一致性 + 裸版本号 ---- -if (/-ota$/i.test(version)) { +// ---- A1. 前端组: 裸版本号 ---- +if (/-ota$/i.test(webVersion)) { 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)') failed = true } -const mismatched = sources.filter((s) => s.version !== version) -if (mismatched.length) { +// ---- A2. 外壳组内一致 ---- +const shellBroken = shellSources.filter((s) => s.version !== shellSources[0].version || !s.version) +if (shellBroken.length) { console.error('') console.error('============================================================') - console.error('[check-version] 版本号不一致, 构建中止') + console.error('[check-version] 外壳版本号不一致 (tauri.conf.json ↔ Cargo.toml ↔ Cargo.lock), 构建中止') console.error('============================================================') - for (const s of mismatched) { - console.error(` ${s.file}: ${s.version || '(缺失)'} ≠ package.json: ${version}`) + for (const s of shellBroken) { + 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 } -// ---- B. 分支对应 ---- +// ---- B. 分支对应 (仅前端组; 外壳在 android-* 分支独立演进, 不参与) ---- const m = /^dev-(\d+)(?:\.(\d+))?$/.exec(branch) if (!m) { console.log('[check-version] 非 dev-<版本> 分支, 跳过分支对应校验') } else { const want = m[2] === undefined ? `${m[1]}.` : `${m[1]}.${m[2]}` - if (!version.startsWith(want)) { + if (!webVersion.startsWith(want)) { console.error('') - console.error(`[check-version] 版本与分支不一致: 分支 "${branch}" 要求版本以 ${want} 开头, 实际是 ${version}`) - console.error('[check-version] 请修改 package.json 的 version, 并同步 tauri.conf.json / Cargo.toml / Cargo.lock') + console.error(`[check-version] 版本与分支不一致: 分支 "${branch}" 要求前端版本以 ${want} 开头, 实际是 ${webVersion}`) + console.error('[check-version] 请修改 package.json 的 version') failed = true } else { - console.log(`[check-version] 分支一致 (${branch} ↔ ${version})`) + console.log(`[check-version] 分支一致 (${branch} ↔ ${webVersion})`) } } diff --git a/server/routes/checkin.js b/server/routes/checkin.js index 2c41ccb..5a28016 100644 --- a/server/routes/checkin.js +++ b/server/routes/checkin.js @@ -1,328 +1,345 @@ -import { Router } from 'express' -import { prisma } from '../db.js' -import { authRequired } from '../middleware.js' -import { - toDateStr, addDays, periodRange, periodDays, expectedPerDay, - periodIsClosed, eachDay, PERIODS, PERIOD_LABEL, -} from '../checkin/period.js' - -const router = Router() -router.use(authRequired) - -// 补打卡允许的回溯天数 (F-CK-10) -const BACKFILL_DAYS = 7 - -const DATE_RE = /^\d{4}-\d{2}-\d{2}$/ -const MONTH_RE = /^\d{4}-\d{2}$/ - -function normDate(v) { - const s = String(v || '') - return DATE_RE.test(s) ? s : toDateStr() -} - -// ---------- 统计辅助 ---------- - -// 记录 -> { habitId: { date: count } } -function groupRecords(records) { - const map = new Map() - for (const r of records) { - if (!map.has(r.habitId)) map.set(r.habitId, new Map()) - const byDate = map.get(r.habitId) - byDate.set(r.date, (byDate.get(r.date) || 0) + 1) - } - return map -} - -function countInRange(byDate, start, end) { - let n = 0 - eachDay(start, end, (d) => { n += byDate.get(d) || 0 }) - return n -} - -// 连续达标的周期数: 当前周期未结束且未达标 -> 跳过(不算断签), 不是中断 -function computeStreak(habit, byDate, today) { - let cursor = today - let streak = 0 - for (let i = 0; i < 400; i++) { - const { start } = periodRange(habit.period, cursor) - const done = countInRange(byDate, start, periodRange(habit.period, cursor).end) - if (done >= habit.target) { - streak++ - } else if (periodIsClosed(habit.period, cursor, today)) { - break - } - const prevEnd = addDays(start, -1) - if (prevEnd < '1970-01-01') break - cursor = prevEnd - } - return streak -} - -async function loadActiveHabits(userId) { - return prisma.habit.findMany({ - where: { userId, archived: false }, - orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }], - }) -} - -// 组装单个打卡项的展示态 -function buildItem(habit, byDate, date, today) { - const { start, end } = periodRange(habit.period, date) - const done = countInRange(byDate, start, end) - const target = Math.max(1, habit.target) - const onDate = byDate.get(date) || 0 - const expPerDay = expectedPerDay(habit.period, target, date) - return { - id: habit.id, - name: habit.name, - period: habit.period, - periodLabel: PERIOD_LABEL[habit.period] || '每天', - target, - color: habit.color, - icon: habit.icon, - note: habit.note, - archived: habit.archived, - periodStart: start, - periodEnd: end, - periodDays: periodDays(habit.period, date), - done, - remaining: Math.max(0, target - done), - percent: Math.min(1, target > 0 ? done / target : 1), - completed: done >= target, - over: Math.max(0, done - target), - countOnDate: onDate, - expectedPerDay: expPerDay, - dayDone: onDate >= expPerDay, - streak: computeStreak(habit, byDate, today), - } -} - -// 拉取 [start, end] 内的打卡记录并分组 -async function loadRecords(userId, habits, ranges) { - let min = ranges[0]?.start - let max = ranges[0]?.end - for (const r of ranges) { - if (r.start < min) min = r.start - if (r.end > max) max = r.end - } - if (!min || !max) return new Map() - const records = await prisma.checkIn.findMany({ - where: { userId, habitId: { in: habits.map((h) => h.id) }, date: { gte: min, lte: max } }, - select: { habitId: true, date: true }, - }) - return groupRecords(records) -} - -// ---------- 打卡项 ---------- - -// GET /api/checkin/habits?date=YYYY-MM-DD 今日(或指定日)待办 + 周期进度 -router.get('/habits', async (req, res, next) => { - try { - const userId = req.user.sub - const today = toDateStr() - const date = normDate(req.query.date) - const habits = await loadActiveHabits(userId) - const ranges = habits.map((h) => periodRange(h.period, date)) - const grouped = await loadRecords(userId, habits, ranges.length ? ranges : [{ start: date, end: date }]) - const items = habits.map((h) => buildItem(h, grouped.get(h.id) || new Map(), date, today)) - const doneCount = items.filter((i) => i.completed).length - res.json({ today, date, items, summary: { total: items.length, done: doneCount } }) - } catch (e) { next(e) } -}) - -// POST /api/checkin/habits 新建打卡项 -router.post('/habits', async (req, res, next) => { - try { - const userId = req.user.sub - const { name, period = 'day', target = 1, color = '#1677ff', icon = '', note = '' } = req.body || {} - const cleanName = String(name || '').trim() - if (!cleanName) return res.status(400).json({ error: '名称不能为空' }) - if (cleanName.length > 20) return res.status(400).json({ error: '名称不能超过 20 个字' }) - if (!PERIODS.includes(period)) return res.status(400).json({ error: '周期类型不合法' }) - const t = Math.floor(Number(target)) - if (!Number.isFinite(t) || t < 1) return res.status(400).json({ error: '目标次数需为 ≥1 的整数' }) - const row = await prisma.habit.create({ - data: { userId, name: cleanName, period, target: t, color: String(color), icon: String(icon), note: String(note || '') }, - }) - res.json({ item: row }) - } catch (e) { next(e) } -}) - -// PUT /api/checkin/habits/:id 编辑 -router.put('/habits/:id', async (req, res, next) => { - try { - const userId = req.user.sub - const id = Number(req.params.id) - const exist = await prisma.habit.findFirst({ where: { id, userId } }) - if (!exist) return res.status(404).json({ error: '打卡项不存在' }) - const { name, period, target, color, icon, note, archived, sortOrder } = req.body || {} - const data = {} - if (name !== undefined) { - const n = String(name).trim() - if (!n) return res.status(400).json({ error: '名称不能为空' }) - if (n.length > 20) return res.status(400).json({ error: '名称不能超过 20 个字' }) - data.name = n - } - if (period !== undefined) { - if (!PERIODS.includes(period)) return res.status(400).json({ error: '周期类型不合法' }) - data.period = period - } - if (target !== undefined) { - const t = Math.floor(Number(target)) - if (!Number.isFinite(t) || t < 1) return res.status(400).json({ error: '目标次数需为 ≥1 的整数' }) - data.target = t - } - if (color !== undefined) data.color = String(color) - if (icon !== undefined) data.icon = String(icon) - if (note !== undefined) data.note = String(note) - if (archived !== undefined) data.archived = !!archived - if (sortOrder !== undefined) data.sortOrder = Number(sortOrder) || 0 - const row = await prisma.habit.update({ where: { id }, data }) - res.json({ item: row }) - } catch (e) { next(e) } -}) - -// DELETE /api/checkin/habits/:id?hard=1 默认归档(保留历史), hard=1 物理删除 -router.delete('/habits/:id', async (req, res, next) => { - try { - const userId = req.user.sub - const id = Number(req.params.id) - const exist = await prisma.habit.findFirst({ where: { id, userId } }) - if (!exist) return res.status(404).json({ error: '打卡项不存在' }) - if (req.query.hard === '1') { - await prisma.checkIn.deleteMany({ where: { habitId: id } }) - await prisma.habit.delete({ where: { id } }) - return res.json({ ok: true, removed: true }) - } - const row = await prisma.habit.update({ where: { id }, data: { archived: true } }) - res.json({ ok: true, removed: false, item: row }) - } catch (e) { next(e) } -}) - -// ---------- 打卡 / 撤销 ---------- - -// POST /api/checkin/habits/:id/check { date?, note? } 打一次卡 -router.post('/habits/:id/check', async (req, res, next) => { - try { - const userId = req.user.sub - const id = Number(req.params.id) - const habit = await prisma.habit.findFirst({ where: { id, userId } }) - if (!habit) return res.status(404).json({ error: '打卡项不存在' }) - const today = toDateStr() - const date = normDate((req.body || {}).date) - if (date > today) return res.status(400).json({ error: '不能为未来日期打卡' }) - if (date < addDays(today, -BACKFILL_DAYS)) { - return res.status(400).json({ error: `最多只能补打最近 ${BACKFILL_DAYS} 天` }) - } - const dayRecords = await prisma.checkIn.findMany({ where: { habitId: id, date }, select: { seq: true } }) - const seq = dayRecords.reduce((m, r) => Math.max(m, r.seq), 0) + 1 - const note = String((req.body || {}).note || '').slice(0, 200) - await prisma.checkIn.create({ - data: { habitId: id, userId, date, seq, note, source: date === today ? 'user' : 'backfill' }, - }) - const grouped = await loadRecords(userId, [habit], [periodRange(habit.period, date)]) - res.json({ item: buildItem(habit, grouped.get(habit.id) || new Map(), date, today), date }) - } catch (e) { - // 唯一约束冲突 = 并发重复提交, 视为已打卡 - if (e && e.code === 'P2002') return res.status(409).json({ error: '该次打卡已记录' }) - next(e) - } -}) - -// DELETE /api/checkin/habits/:id/check?date= 撤销本周期内最近一次打卡 -router.delete('/habits/:id/check', async (req, res, next) => { - try { - const userId = req.user.sub - const id = Number(req.params.id) - const habit = await prisma.habit.findFirst({ where: { id, userId } }) - if (!habit) return res.status(404).json({ error: '打卡项不存在' }) - const today = toDateStr() - const date = normDate(req.query.date) - const { start, end } = periodRange(habit.period, date) - const last = await prisma.checkIn.findFirst({ - where: { habitId: id, date: { gte: start, lte: end } }, - orderBy: [{ date: 'desc' }, { seq: 'desc' }], - }) - if (!last) return res.status(400).json({ error: '本周期还没有打卡记录' }) - await prisma.checkIn.delete({ where: { id: last.id } }) - const grouped = await loadRecords(userId, [habit], [{ start, end }]) - res.json({ item: buildItem(habit, grouped.get(habit.id) || new Map(), date, today), date }) - } catch (e) { next(e) } -}) - -// ---------- 某日详情 / 月历 ---------- - -// GET /api/checkin/day?date=YYYY-MM-DD 当天每个打卡项的明细 (含可打卡/只读标记) -router.get('/day', async (req, res, next) => { - try { - const userId = req.user.sub - const today = toDateStr() - const date = normDate(req.query.date) - const habits = await loadActiveHabits(userId) - const ranges = habits.map((h) => periodRange(h.period, date)) - const grouped = await loadRecords(userId, habits, ranges.length ? ranges : [{ start: date, end: date }]) - const items = habits.map((h) => { - const base = buildItem(h, grouped.get(h.id) || new Map(), date, today) - return { ...base, canCheck: date <= today && date >= addDays(today, -BACKFILL_DAYS) } - }) - res.json({ today, date, isToday: date === today, isFuture: date > today, items }) - } catch (e) { next(e) } -}) - -// GET /api/checkin/calendar?month=YYYY-MM 月历: 每天完成情况 + 当月完成率 -router.get('/calendar', async (req, res, next) => { - try { - const userId = req.user.sub - const today = toDateStr() - const month = MONTH_RE.test(String(req.query.month || '')) ? String(req.query.month) : today.slice(0, 7) - const start = month + '-01' - const end = addDays(monthStartNext(month), -1) - const habits = await prisma.habit.findMany({ where: { userId }, orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }] }) - const active = habits.filter((h) => !h.archived) - const records = await prisma.checkIn.findMany({ - where: { userId, date: { gte: start, lte: end } }, - select: { habitId: true, date: true }, - }) - const grouped = groupRecords(records) - const days = [] - let expTotal = 0 - let doneTotal = 0 - eachDay(start, end, (d) => { - let expected = 0 - let actual = 0 - let over = false - const detail = [] - for (const h of active) { - if (toDateStr(h.createdAt) > d) continue // 创建之前的日期不计入期望 - const exp = expectedPerDay(h.period, Math.max(1, h.target), d) - const cnt = (grouped.get(h.id) || new Map()).get(d) || 0 - expected += exp - actual += Math.min(cnt, exp) - if (cnt > exp) over = true - detail.push({ id: h.id, name: h.name, color: h.color, count: cnt, expected: exp, done: cnt >= exp }) - } - const isFuture = d > today - if (!isFuture) { expTotal += expected; doneTotal += actual } - let status = 'empty' - if (expected > 0 && actual >= expected) status = over ? 'over' : 'full' - else if (actual > 0) status = 'partial' - days.push({ - date: d, expected, actual, status, isToday: d === today, isFuture, - habits: detail, - }) - }) - res.json({ - today, month, days, - rate: expTotal > 0 ? Math.min(1, doneTotal / expTotal) : 0, - habits: active.map((h) => ({ id: h.id, name: h.name, color: h.color, period: h.period, target: h.target })), - }) - } catch (e) { next(e) } -}) - -function monthStartNext(month) { - const [y, m] = month.split('-').map(Number) - const d = new Date(Date.UTC(y, m, 1)) // m 已 +1 (下月) - return d.toISOString().slice(0, 10) -} - -export default router +import { Router } from 'express' +import { prisma } from '../db.js' +import { authRequired } from '../middleware.js' +import { + toDateStr, addDays, periodRange, periodDays, expectedPerDay, + periodIsClosed, eachDay, PERIODS, PERIOD_LABEL, +} from '../checkin/period.js' + +const router = Router() +router.use(authRequired) + +// 补打卡允许的回溯天数 (F-CK-10) +const BACKFILL_DAYS = 7 + +const DATE_RE = /^\d{4}-\d{2}-\d{2}$/ +const MONTH_RE = /^\d{4}-\d{2}$/ + +function normDate(v) { + const s = String(v || '') + return DATE_RE.test(s) ? s : toDateStr() +} + +// ---------- 统计辅助 ---------- + +// 记录 -> { habitId: { date: count } } +function groupRecords(records) { + const map = new Map() + for (const r of records) { + if (!map.has(r.habitId)) map.set(r.habitId, new Map()) + const byDate = map.get(r.habitId) + byDate.set(r.date, (byDate.get(r.date) || 0) + 1) + } + return map +} + +function countInRange(byDate, start, end) { + let n = 0 + eachDay(start, end, (d) => { n += byDate.get(d) || 0 }) + return n +} + +// 连续达标的周期数: 当前周期未结束且未达标 -> 跳过(不算断签), 不是中断 +function computeStreak(habit, byDate, today) { + let cursor = today + let streak = 0 + for (let i = 0; i < 400; i++) { + const { start } = periodRange(habit.period, cursor) + const done = countInRange(byDate, start, periodRange(habit.period, cursor).end) + if (done >= habit.target) { + streak++ + } else if (periodIsClosed(habit.period, cursor, today)) { + break + } + const prevEnd = addDays(start, -1) + if (prevEnd < '1970-01-01') break + cursor = prevEnd + } + return streak +} + +async function loadActiveHabits(userId) { + return prisma.habit.findMany({ + where: { userId, archived: false }, + orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }], + }) +} + +// 组装单个打卡项的展示态 +function buildItem(habit, byDate, date, today) { + const { start, end } = periodRange(habit.period, date) + const done = countInRange(byDate, start, end) + const target = Math.max(1, habit.target) + const onDate = byDate.get(date) || 0 + const expPerDay = expectedPerDay(habit.period, target, date) + return { + id: habit.id, + name: habit.name, + period: habit.period, + sortOrder: habit.sortOrder, + periodLabel: PERIOD_LABEL[habit.period] || '每天', + target, + color: habit.color, + icon: habit.icon, + note: habit.note, + archived: habit.archived, + periodStart: start, + periodEnd: end, + periodDays: periodDays(habit.period, date), + done, + remaining: Math.max(0, target - done), + percent: Math.min(1, target > 0 ? done / target : 1), + completed: done >= target, + over: Math.max(0, done - target), + countOnDate: onDate, + expectedPerDay: expPerDay, + dayDone: onDate >= expPerDay, + streak: computeStreak(habit, byDate, today), + } +} + +// 拉取 [start, end] 内的打卡记录并分组 +async function loadRecords(userId, habits, ranges) { + let min = ranges[0]?.start + let max = ranges[0]?.end + for (const r of ranges) { + if (r.start < min) min = r.start + if (r.end > max) max = r.end + } + if (!min || !max) return new Map() + const records = await prisma.checkIn.findMany({ + where: { userId, habitId: { in: habits.map((h) => h.id) }, date: { gte: min, lte: max } }, + select: { habitId: true, date: true }, + }) + return groupRecords(records) +} + +// ---------- 打卡项 ---------- + +// GET /api/checkin/habits?date=YYYY-MM-DD 今日(或指定日)待办 + 周期进度 +router.get('/habits', async (req, res, next) => { + try { + const userId = req.user.sub + const today = toDateStr() + const date = normDate(req.query.date) + const habits = await loadActiveHabits(userId) + const ranges = habits.map((h) => periodRange(h.period, date)) + const grouped = await loadRecords(userId, habits, ranges.length ? ranges : [{ start: date, end: date }]) + const items = habits.map((h) => buildItem(h, grouped.get(h.id) || new Map(), date, today)) + const doneCount = items.filter((i) => i.completed).length + res.json({ today, date, items, summary: { total: items.length, done: doneCount } }) + } catch (e) { next(e) } +}) + +// POST /api/checkin/habits 新建打卡项 +router.post('/habits', async (req, res, next) => { + try { + const userId = req.user.sub + const { name, period = 'day', target = 1, color = '#1677ff', icon = '', note = '' } = req.body || {} + const cleanName = String(name || '').trim() + if (!cleanName) return res.status(400).json({ error: '名称不能为空' }) + if (cleanName.length > 20) return res.status(400).json({ error: '名称不能超过 20 个字' }) + if (!PERIODS.includes(period)) return res.status(400).json({ error: '周期类型不合法' }) + const t = Math.floor(Number(target)) + if (!Number.isFinite(t) || t < 1) return res.status(400).json({ error: '目标次数需为 ≥1 的整数' }) + const last = await prisma.habit.findFirst({ where: { userId }, orderBy: { sortOrder: 'desc' }, select: { sortOrder: true } }) + const row = await prisma.habit.create({ + 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 }) + } catch (e) { next(e) } +}) + +// PUT /api/checkin/habits/:id 编辑 +router.put('/habits/:id', async (req, res, next) => { + try { + const userId = req.user.sub + const id = Number(req.params.id) + const exist = await prisma.habit.findFirst({ where: { id, userId } }) + if (!exist) return res.status(404).json({ error: '打卡项不存在' }) + const { name, period, target, color, icon, note, archived, sortOrder } = req.body || {} + const data = {} + if (name !== undefined) { + const n = String(name).trim() + if (!n) return res.status(400).json({ error: '名称不能为空' }) + if (n.length > 20) return res.status(400).json({ error: '名称不能超过 20 个字' }) + data.name = n + } + if (period !== undefined) { + if (!PERIODS.includes(period)) return res.status(400).json({ error: '周期类型不合法' }) + data.period = period + } + if (target !== undefined) { + const t = Math.floor(Number(target)) + if (!Number.isFinite(t) || t < 1) return res.status(400).json({ error: '目标次数需为 ≥1 的整数' }) + data.target = t + } + if (color !== undefined) data.color = String(color) + if (icon !== undefined) data.icon = String(icon) + if (note !== undefined) data.note = String(note) + if (archived !== undefined) data.archived = !!archived + if (sortOrder !== undefined) data.sortOrder = Number(sortOrder) || 0 + const row = await prisma.habit.update({ where: { id }, data }) + res.json({ item: row }) + } catch (e) { next(e) } +}) + +// 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 物理删除 +router.delete('/habits/:id', async (req, res, next) => { + try { + const userId = req.user.sub + const id = Number(req.params.id) + const exist = await prisma.habit.findFirst({ where: { id, userId } }) + if (!exist) return res.status(404).json({ error: '打卡项不存在' }) + if (req.query.hard === '1') { + await prisma.checkIn.deleteMany({ where: { habitId: id } }) + await prisma.habit.delete({ where: { id } }) + return res.json({ ok: true, removed: true }) + } + const row = await prisma.habit.update({ where: { id }, data: { archived: true } }) + res.json({ ok: true, removed: false, item: row }) + } catch (e) { next(e) } +}) + +// ---------- 打卡 / 撤销 ---------- + +// POST /api/checkin/habits/:id/check { date?, note? } 打一次卡 +router.post('/habits/:id/check', async (req, res, next) => { + try { + const userId = req.user.sub + const id = Number(req.params.id) + const habit = await prisma.habit.findFirst({ where: { id, userId } }) + if (!habit) return res.status(404).json({ error: '打卡项不存在' }) + const today = toDateStr() + const date = normDate((req.body || {}).date) + if (date > today) return res.status(400).json({ error: '不能为未来日期打卡' }) + if (date < addDays(today, -BACKFILL_DAYS)) { + return res.status(400).json({ error: `最多只能补打最近 ${BACKFILL_DAYS} 天` }) + } + const dayRecords = await prisma.checkIn.findMany({ where: { habitId: id, date }, select: { seq: true } }) + const seq = dayRecords.reduce((m, r) => Math.max(m, r.seq), 0) + 1 + const note = String((req.body || {}).note || '').slice(0, 200) + await prisma.checkIn.create({ + data: { habitId: id, userId, date, seq, note, source: date === today ? 'user' : 'backfill' }, + }) + const grouped = await loadRecords(userId, [habit], [periodRange(habit.period, date)]) + res.json({ item: buildItem(habit, grouped.get(habit.id) || new Map(), date, today), date }) + } catch (e) { + // 唯一约束冲突 = 并发重复提交, 视为已打卡 + if (e && e.code === 'P2002') return res.status(409).json({ error: '该次打卡已记录' }) + next(e) + } +}) + +// DELETE /api/checkin/habits/:id/check?date= 撤销本周期内最近一次打卡 +router.delete('/habits/:id/check', async (req, res, next) => { + try { + const userId = req.user.sub + const id = Number(req.params.id) + const habit = await prisma.habit.findFirst({ where: { id, userId } }) + if (!habit) return res.status(404).json({ error: '打卡项不存在' }) + const today = toDateStr() + const date = normDate(req.query.date) + const { start, end } = periodRange(habit.period, date) + const last = await prisma.checkIn.findFirst({ + where: { habitId: id, date: { gte: start, lte: end } }, + orderBy: [{ date: 'desc' }, { seq: 'desc' }], + }) + if (!last) return res.status(400).json({ error: '本周期还没有打卡记录' }) + await prisma.checkIn.delete({ where: { id: last.id } }) + const grouped = await loadRecords(userId, [habit], [{ start, end }]) + res.json({ item: buildItem(habit, grouped.get(habit.id) || new Map(), date, today), date }) + } catch (e) { next(e) } +}) + +// ---------- 某日详情 / 月历 ---------- + +// GET /api/checkin/day?date=YYYY-MM-DD 当天每个打卡项的明细 (含可打卡/只读标记) +router.get('/day', async (req, res, next) => { + try { + const userId = req.user.sub + const today = toDateStr() + const date = normDate(req.query.date) + const habits = await loadActiveHabits(userId) + const ranges = habits.map((h) => periodRange(h.period, date)) + const grouped = await loadRecords(userId, habits, ranges.length ? ranges : [{ start: date, end: date }]) + const items = habits.map((h) => { + const base = buildItem(h, grouped.get(h.id) || new Map(), date, today) + return { ...base, canCheck: date <= today && date >= addDays(today, -BACKFILL_DAYS) } + }) + res.json({ today, date, isToday: date === today, isFuture: date > today, items }) + } catch (e) { next(e) } +}) + +// GET /api/checkin/calendar?month=YYYY-MM 月历: 每天完成情况 + 当月完成率 +router.get('/calendar', async (req, res, next) => { + try { + const userId = req.user.sub + const today = toDateStr() + const month = MONTH_RE.test(String(req.query.month || '')) ? String(req.query.month) : today.slice(0, 7) + const start = month + '-01' + const end = addDays(monthStartNext(month), -1) + const habits = await prisma.habit.findMany({ where: { userId }, orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }] }) + const active = habits.filter((h) => !h.archived) + const records = await prisma.checkIn.findMany({ + where: { userId, date: { gte: start, lte: end } }, + select: { habitId: true, date: true }, + }) + const grouped = groupRecords(records) + const days = [] + let expTotal = 0 + let doneTotal = 0 + eachDay(start, end, (d) => { + let expected = 0 + let actual = 0 + let over = false + const detail = [] + for (const h of active) { + if (toDateStr(h.createdAt) > d) continue // 创建之前的日期不计入期望 + const exp = expectedPerDay(h.period, Math.max(1, h.target), d) + const cnt = (grouped.get(h.id) || new Map()).get(d) || 0 + expected += exp + actual += Math.min(cnt, exp) + if (cnt > exp) over = true + detail.push({ id: h.id, name: h.name, color: h.color, count: cnt, expected: exp, done: cnt >= exp }) + } + const isFuture = d > today + if (!isFuture) { expTotal += expected; doneTotal += actual } + let status = 'empty' + if (expected > 0 && actual >= expected) status = over ? 'over' : 'full' + else if (actual > 0) status = 'partial' + days.push({ + date: d, expected, actual, status, isToday: d === today, isFuture, + habits: detail, + }) + }) + res.json({ + today, month, days, + rate: expTotal > 0 ? Math.min(1, doneTotal / expTotal) : 0, + habits: active.map((h) => ({ id: h.id, name: h.name, color: h.color, period: h.period, target: h.target })), + }) + } catch (e) { next(e) } +}) + +function monthStartNext(month) { + const [y, m] = month.split('-').map(Number) + const d = new Date(Date.UTC(y, m, 1)) // m 已 +1 (下月) + return d.toISOString().slice(0, 10) +} + +export default router diff --git a/server/routes/ota.js b/server/routes/ota.js index 1242a49..21e92ff 100644 --- a/server/routes/ota.js +++ b/server/routes/ota.js @@ -1,102 +1,105 @@ -// OTA 升级接口 (兼容 tauri-plugin-hotswap 服务端约定) -// GET /api/ota/check/:current_sequence -// - 客户端传当前 sequence, 服务端判断是否有更新 -// - 有: 200 + manifest JSON -// - 无: 204 No Content -// GET /api/ota/bundle/:filename -// - 下载前端 bundle (tar.gz), 供 manifest.url 引用 -// GET /api/ota/manifest-web -// - 给 Web 浏览器用的简化版 manifest (无签名, 仅展示用) -// -// 数据源: 环境变量 OTA_PACKAGE_DIR 指向的目录, 里面要放: -// - latest.json (由 scripts/build-ota.mjs 生成) -// { sequence, version, min_binary_version, signature, notes, bundle, mandatory, pub_date, bundle_size } -// - (tar.gz 升级包) -import { Router } from 'express' -import fs from 'node:fs' -import path from 'node:path' - -const router = Router() - -function resolveOtaDir() { - const dir = process.env.OTA_PACKAGE_DIR - ? path.resolve(process.env.OTA_PACKAGE_DIR) - : path.resolve(process.cwd(), 'ota-package') - return dir -} - -function readLatest() { - const p = path.join(resolveOtaDir(), 'latest.json') - if (!fs.existsSync(p)) return null - try { - return JSON.parse(fs.readFileSync(p, 'utf8')) - } catch (e) { - console.error('[ota] latest.json 解析失败:', e.message) - return null - } -} - -// GET /api/ota/check/:current_sequence -// 客户端实际请求 URL 是 endpoint 中 {{current_sequence}} 替换后的形式 -router.get('/ota/check/:current_sequence', (req, res) => { - const currentSeq = Number(req.params.current_sequence) - if (!Number.isFinite(currentSeq) || currentSeq < 0) { - return res.status(400).json({ error: '无效的 sequence' }) - } - const latest = readLatest() - if (!latest) { - // 无可用包, 按约定返回 204 - return res.status(204).end() - } - // 不新于当前则 204 - if (typeof latest.sequence === 'number' && latest.sequence <= currentSeq) { - return res.status(204).end() - } - // 检查 binary_version 兼容性 (客户端会再校验一次, 这里只做参考过滤) - // 客户端传 binary_version query: 暂不强制, 让客户端兜底 - const dir = resolveOtaDir() - const base = process.env.OTA_PUBLIC_BASE || '' - res.json({ - version: latest.version, - sequence: latest.sequence, - min_binary_version: latest.min_binary_version || '0.0.0', - url: (base ? base : '') + '/api/ota/bundle/' + encodeURIComponent(latest.bundle), - signature: latest.signature, - notes: latest.notes || '', - pub_date: latest.pub_date || new Date().toISOString(), - mandatory: !!latest.mandatory, - bundle_size: latest.bundle_size || 0, - }) -}) - -// GET /api/ota/bundle/:filename - 流式下载 bundle -router.get('/ota/bundle/:filename', (req, res) => { - const dir = resolveOtaDir() - // 防越界: 只取 basename - const name = path.basename(req.params.filename) - const p = path.join(dir, name) - if (!fs.existsSync(p)) return res.status(404).json({ error: 'bundle 不存在' }) - const stat = fs.statSync(p) - res.setHeader('Content-Type', 'application/gzip') - res.setHeader('Content-Length', stat.size) - res.setHeader('Content-Disposition', 'attachment; filename="' + name + '"') - fs.createReadStream(p).pipe(res) -}) - -// GET /api/ota/manifest-web - Web 浏览器用的简化版 -// 客户端会拿这个跟当前 build_id 比较, 有新版本就提示用户刷新 -router.get('/ota/manifest-web', (_req, res) => { - const latest = readLatest() - if (!latest) return res.json({ available: false }) - res.json({ - available: true, - version: latest.version, - build_id: latest.build_id || latest.version, - note: latest.notes || '', - size: latest.bundle_size || 0, - }) -}) - -// GET /api/ota/info 已删除: 诊断接口会暴露服务器路径与签名, 且无调用方 - -export default router +// OTA 升级接口 (兼容 tauri-plugin-hotswap 服务端约定) +// GET /api/ota/check/:current_sequence +// - 客户端传当前 sequence, 服务端判断是否有更新 +// - 有: 200 + manifest JSON +// - 无: 204 No Content +// GET /api/ota/bundle/:filename +// - 下载前端 bundle (tar.gz), 供 manifest.url 引用 +// GET /api/ota/manifest-web +// - 给 Web 浏览器用的简化版 manifest (无签名, 仅展示用) +// +// 数据源: 环境变量 OTA_PACKAGE_DIR 指向的目录, 里面要放: +// - latest.json (由 scripts/build-ota.mjs 生成) +// { sequence, version, min_binary_version, signature, notes, bundle, mandatory, pub_date, bundle_size } +// - (tar.gz 升级包) +import { Router } from 'express' +import fs from 'node:fs' +import path from 'node:path' + +const router = Router() + +function resolveOtaDir() { + const dir = process.env.OTA_PACKAGE_DIR + ? path.resolve(process.env.OTA_PACKAGE_DIR) + : path.resolve(process.cwd(), 'ota-package') + return dir +} + +function readLatest() { + const p = path.join(resolveOtaDir(), 'latest.json') + if (!fs.existsSync(p)) return null + try { + return JSON.parse(fs.readFileSync(p, 'utf8')) + } catch (e) { + console.error('[ota] latest.json 解析失败:', e.message) + return null + } +} + +// GET /api/ota/check/:current_sequence +// 客户端实际请求 URL 是 endpoint 中 {{current_sequence}} 替换后的形式 +router.get('/ota/check/:current_sequence', (req, res) => { + const currentSeq = Number(req.params.current_sequence) + if (!Number.isFinite(currentSeq) || currentSeq < 0) { + return res.status(400).json({ error: '无效的 sequence' }) + } + const latest = readLatest() + if (!latest) { + // 无可用包, 按约定返回 204 + return res.status(204).end() + } + // 不新于当前则 204 + if (typeof latest.sequence === 'number' && latest.sequence <= currentSeq) { + return res.status(204).end() + } + // 检查 binary_version 兼容性 (客户端会再校验一次, 这里只做参考过滤) + // 客户端传 binary_version query: 暂不强制, 让客户端兜底 + const dir = resolveOtaDir() + const base = process.env.OTA_PUBLIC_BASE || '' + res.json({ + version: latest.version, + sequence: latest.sequence, + min_binary_version: latest.min_binary_version || '0.0.0', + url: (base ? base : '') + '/api/ota/bundle/' + encodeURIComponent(latest.bundle), + signature: latest.signature, + notes: latest.notes || '', + commit: latest.commit || '', + pub_date: latest.pub_date || new Date().toISOString(), + mandatory: !!latest.mandatory, + bundle_size: latest.bundle_size || 0, + }) +}) + +// GET /api/ota/bundle/:filename - 流式下载 bundle +router.get('/ota/bundle/:filename', (req, res) => { + const dir = resolveOtaDir() + // 防越界: 只取 basename + const name = path.basename(req.params.filename) + const p = path.join(dir, name) + if (!fs.existsSync(p)) return res.status(404).json({ error: 'bundle 不存在' }) + const stat = fs.statSync(p) + res.setHeader('Content-Type', 'application/gzip') + res.setHeader('Content-Length', stat.size) + res.setHeader('Content-Disposition', 'attachment; filename="' + name + '"') + fs.createReadStream(p).pipe(res) +}) + +// GET /api/ota/manifest-web - Web 浏览器用的简化版 +// 客户端会拿这个跟当前 build_id 比较, 有新版本就提示用户刷新 +router.get('/ota/manifest-web', (_req, res) => { + const latest = readLatest() + if (!latest) return res.json({ available: false }) + res.json({ + available: true, + version: latest.version, + sequence: latest.sequence, + commit: latest.commit || '', + build_id: latest.build_id || latest.version, + note: latest.notes || '', + size: latest.bundle_size || 0, + }) +}) + +// GET /api/ota/info 已删除: 诊断接口会暴露服务器路径与签名, 且无调用方 + +export default router diff --git a/server/routes/projects.js b/server/routes/projects.js index 4183983..6eecd6f 100644 --- a/server/routes/projects.js +++ b/server/routes/projects.js @@ -38,6 +38,20 @@ export async function ensurePersonalProject(userId) { 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) { return { id: p.id, @@ -47,6 +61,8 @@ function projectSummary(p) { archived: p.archived, createdAt: p.createdAt, updatedAt: p.updatedAt, + taskKinds: normTaskKinds(p.taskKinds) || DEFAULT_TASK_KINDS, + icon: p.icon || null, memberCount: p._count?.members ?? undefined, taskCount: p._count?.tasks ?? undefined, } @@ -119,7 +135,7 @@ router.put('/:id', async (req, res, next) => { const userId = req.user.sub const project = await loadProjectForUser(req.params.id, userId) if (!project) return res.status(404).json({ error: '项目不存在或无权限' }) - const { name, archived, notes } = req.body || {} + const { name, archived, notes, taskKinds, icon } = req.body || {} const data = {} if (name !== undefined) { 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} 字` }) 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 // 项目信息块 (T-PJ-13): 任意多个 { title, content }, 整体替换 if (notes !== undefined) { diff --git a/server/routes/tasks.js b/server/routes/tasks.js index 1b29944..b62f235 100644 --- a/server/routes/tasks.js +++ b/server/routes/tasks.js @@ -70,10 +70,11 @@ router.post('/projects/:id/tasks', async (req, res, next) => { const project = await loadProjectForUser(req.params.id, userId) if (!project) return res.status(404).json({ error: '项目不存在或无权限' }) - const { title, description = '', parentId, assigneeId, memberIds = [], tags = [] } = req.body || {} + const { title, description = '', parentId, assigneeId, memberIds = [], tags = [], kind, markDate } = req.body || {} const cleanTitle = String(title || '').trim() if (!cleanTitle) return res.status(400).json({ error: '任务标题不能为空' }) - if (cleanTitle.length > 200) return res.status(400).json({ error: '任务标题不能超过 200 字' }) + if (cleanTitle.length > 30) return res.status(400).json({ error: '任务标题不能超过 30 字' }) + const mark = parseMarkDate(markDate) const columns = await loadColumns(project.id) if (!columns.length) return res.status(400).json({ error: '项目没有可用的看板列' }) @@ -107,6 +108,8 @@ router.post('/projects/:id/tasks', async (req, res, next) => { parentId: pid, title: cleanTitle, description: String(description || ''), + kind: kind ? String(kind).trim().slice(0, 20) : null, + markDate: mark, status: firstCol.boundStatus || 'todo', assigneeId: finalAssignee, creatorId: userId, @@ -124,6 +127,27 @@ router.post('/projects/:id/tasks', async (req, res, next) => { } 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 @@ -136,6 +160,14 @@ router.get('/tasks/:id', async (req, res, next) => { } 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 关键: 列/状态联动裁决 router.put('/tasks/:id', async (req, res, next) => { try { @@ -152,10 +184,21 @@ router.put('/tasks/:id', async (req, res, next) => { if (body.title !== undefined) { const t = String(body.title).trim() if (!t) return res.status(400).json({ error: '任务标题不能为空' }) - if (t.length > 200) return res.status(400).json({ error: '任务标题不能超过 200 字' }) + if (t.length > 30) return res.status(400).json({ error: '任务标题不能超过 30 字' }) data.title = t } 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 (!Array.isArray(body.extras)) return res.status(400).json({ error: '扩展信息格式非法' }) data.extras = body.extras.map((e) => ({ diff --git a/src/App.jsx b/src/App.jsx index 33466da..acf78be 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -8,7 +8,6 @@ import MePage from './pages/MePage.jsx' import CheckinPage from './pages/checkin/CheckinPage.jsx' import TaskPage from './pages/task/TaskPage.jsx' import { useAuth } from './auth/AuthContext.jsx' -import useKeyboardInsetWatcher from './hooks/useKeyboardInsetWatcher.js' // 默认落地页: 打卡 (V0.2 起打卡是第一个 Tab 且为默认选中) const HOME_PATH = '/checkin' @@ -31,8 +30,6 @@ function RequireAuth({ children }) { } export default function App() { - // 键盘插入量全局监听: 写入 --kb-inset, 弹层用 .kb-safe 类即可避让键盘 (重点设计 §7.2) - useKeyboardInsetWatcher() return ( } /> diff --git a/src/api/checkin.js b/src/api/checkin.js index e31c020..aca18c8 100644 --- a/src/api/checkin.js +++ b/src/api/checkin.js @@ -1,41 +1,42 @@ -import { api } from './client.js' - -const q = (params) => { - const s = new URLSearchParams() - Object.entries(params || {}).forEach(([k, v]) => { - if (v !== undefined && v !== null && v !== '') s.set(k, v) - }) - const str = s.toString() - return str ? '?' + str : '' -} - -// 打卡 / 日历 -export const checkinApi = { - habits: (date) => api('/checkin/habits' + q({ date })), - day: (date) => api('/checkin/day' + q({ date })), - calendar: (month) => api('/checkin/calendar' + q({ month })), - createHabit: (body) => api('/checkin/habits', { method: 'POST', body }), - updateHabit: (id, body) => api(`/checkin/habits/${id}`, { method: 'PUT', body }), - deleteHabit: (id, hard) => api(`/checkin/habits/${id}${hard ? '?hard=1' : ''}`, { method: 'DELETE' }), - check: (id, date, note) => api(`/checkin/habits/${id}/check`, { method: 'POST', body: { date, note } }), - uncheck: (id, date) => api(`/checkin/habits/${id}/check` + q({ date }), { method: 'DELETE' }), -} - -// 检查清单 -export const checklistApi = { - lists: (archived) => api('/checklists' + q({ archived: archived ? 1 : 0 })), - list: (id) => api(`/checklists/${id}`), - create: (body) => api('/checklists', { method: 'POST', body }), - update: (id, body) => api(`/checklists/${id}`, { method: 'PUT', body }), - remove: (id) => api(`/checklists/${id}`, { method: 'DELETE' }), - duplicate: (id) => api(`/checklists/${id}/duplicate`, { method: 'POST', body: {} }), - addItem: (id, body) => api(`/checklists/${id}/items`, { method: 'POST', body }), - updateItem: (itemId, body) => api(`/checklists/items/${itemId}`, { method: 'PUT', body }), - removeItem: (itemId) => api(`/checklists/items/${itemId}`, { method: 'DELETE' }), - checkAll: (id, done) => api(`/checklists/${id}/check-all`, { method: 'POST', body: { done } }), - templates: () => api('/checklists/templates'), - template: (id) => api(`/checklists/templates/${id}`), - createTemplate: (body) => api('/checklists/templates', { method: 'POST', body }), - updateTemplate: (id, body) => api(`/checklists/templates/${id}`, { method: 'PUT', body }), - removeTemplate: (id) => api(`/checklists/templates/${id}`, { method: 'DELETE' }), -} +import { api } from './client.js' + +const q = (params) => { + const s = new URLSearchParams() + Object.entries(params || {}).forEach(([k, v]) => { + if (v !== undefined && v !== null && v !== '') s.set(k, v) + }) + const str = s.toString() + return str ? '?' + str : '' +} + +// 打卡 / 日历 +export const checkinApi = { + habits: (date) => api('/checkin/habits' + q({ date })), + day: (date) => api('/checkin/day' + q({ date })), + calendar: (month) => api('/checkin/calendar' + q({ month })), + createHabit: (body) => api('/checkin/habits', { method: 'POST', body }), + updateHabit: (id, body) => api(`/checkin/habits/${id}`, { method: 'PUT', body }), + reorderHabits: (ids) => api('/checkin/habits/reorder', { method: 'POST', body: { ids } }), + deleteHabit: (id, hard) => api(`/checkin/habits/${id}${hard ? '?hard=1' : ''}`, { method: 'DELETE' }), + check: (id, date, note) => api(`/checkin/habits/${id}/check`, { method: 'POST', body: { date, note } }), + uncheck: (id, date) => api(`/checkin/habits/${id}/check` + q({ date }), { method: 'DELETE' }), +} + +// 检查清单 +export const checklistApi = { + lists: (archived) => api('/checklists' + q({ archived: archived ? 1 : 0 })), + list: (id) => api(`/checklists/${id}`), + create: (body) => api('/checklists', { method: 'POST', body }), + update: (id, body) => api(`/checklists/${id}`, { method: 'PUT', body }), + remove: (id) => api(`/checklists/${id}`, { method: 'DELETE' }), + duplicate: (id) => api(`/checklists/${id}/duplicate`, { method: 'POST', body: {} }), + addItem: (id, body) => api(`/checklists/${id}/items`, { method: 'POST', body }), + updateItem: (itemId, body) => api(`/checklists/items/${itemId}`, { method: 'PUT', body }), + removeItem: (itemId) => api(`/checklists/items/${itemId}`, { method: 'DELETE' }), + checkAll: (id, done) => api(`/checklists/${id}/check-all`, { method: 'POST', body: { done } }), + templates: () => api('/checklists/templates'), + template: (id) => api(`/checklists/templates/${id}`), + createTemplate: (body) => api('/checklists/templates', { method: 'POST', body }), + updateTemplate: (id, body) => api(`/checklists/templates/${id}`, { method: 'PUT', body }), + removeTemplate: (id) => api(`/checklists/templates/${id}`, { method: 'DELETE' }), +} diff --git a/src/api/ota.js b/src/api/ota.js index ec2214a..2a36de4 100644 --- a/src/api/ota.js +++ b/src/api/ota.js @@ -1,259 +1,278 @@ -// 前端 OTA 封装 -// - Tauri 客户端: 直接用 tauri-plugin-hotswap-api (checkUpdate / applyUpdate / ...) -// - Web 浏览器: 只走我们自己的 /api/ota/manifest, 提示用户手动刷新 (Vite 资源指纹会失效) -// -// 设计原则: -// - Web 端只能"查 + 提示", 不能真下载应用, 避免误导 -// - Tauri 端走 hotswap 插件的官方 API, 签名校验/回滚/资源替换全交给插件 -import { getApiBase } from './client.js' -import { APP_VERSION, BUILD_ID } from '../version.js' - -// 判断是否在 Tauri WebView 内 (有 __TAURI_INTERNALS__ 即 Tauri 环境) -const inTauri = typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window - -// 拼接绝对 URL: 在 Tauri (VITE_API_BASE 有值) 时直连, 否则同源 -function resolveUrl(p) { - const base = getApiBase() - return base ? base + p : p -} - -// 当前版本信息 (前端展示用) -export function getCurrentVersionInfo() { - return { version: APP_VERSION, build: BUILD_ID } -} - -// 启动时: 确认当前资源可用 (Tauri only) -// 失败/异常都不抛, 仅 console.warn -export function initOta() { - if (!inTauri) return - // 动态 import: web 模式根本不打这个 chunk - import('tauri-plugin-hotswap-api') - .then((mod) => mod.notifyReady()) - .then(() => console.log('[ota] notifyReady ok')) - .catch((e) => console.warn('[ota] notifyReady failed:', e)) -} - -// --------------------------------------------------------------------------- -// 判新模式 (V0.2) -// 「开发版本」开关 (我的页 OTA 组内, 默认关闭), 值持久化在设备端 localStorage: -// 开关 关闭 (默认 / 生产口径): 用**版本号**判新 —— 取服务端 manifest 的 version, -// 与当前运行版本按数字字段逐段比较大小 (0.2.0 > 0.1.0)。 -// 这样即使某个旧版本分支被重新构建 (sequence 变大但版本号更小), 也不会把新设备拉回旧版。 -// 开关 打开 (开发口径): 用 **build sequence** 判新 —— 直接走插件 checkUpdate(), -// 只要服务端 sequence 比本地大就算新, 每次提交都能推到设备上, 便于联调。 -// --------------------------------------------------------------------------- -const OTA_DEV_MODE_KEY = 'iboard_ota_dev_mode' - -export function getOtaDevMode() { - try { - return localStorage.getItem(OTA_DEV_MODE_KEY) === '1' - } catch { /* ignore */ } - return false -} - -export function setOtaDevMode(v) { - try { localStorage.setItem(OTA_DEV_MODE_KEY, v ? '1' : '0') } catch { /* ignore */ } -} - -// 版本号 → 数字字段数组 / 比较函数 (纯函数放在 utils, 便于单测) -import { compareVersion, parseVersionFields } from '../utils/version.js' - -export { compareVersion, parseVersionFields } - -// 拉服务端最新 manifest (不分平台) -// /api/ota/check/0: current_sequence 传 0, 只要有 OTA 包就一定返回 200 + manifest -export async function fetchOtaManifest() { - try { - const res = await fetch(resolveUrl('/api/ota/check/0'), { method: 'GET' }) - if (res.status === 204) return null - if (!res.ok) return null - return await res.json() - } catch (e) { - console.warn('[ota] manifest 拉取失败:', e) - return null - } -} - -// 检查更新 -// 返回统一结构, 调用方不关心是 Tauri 还是 web: -// { -// available: boolean, -// reason: 'no_package' | 'same_version' | 'ota_disabled' | 'unsupported' | 'ok', -// info: { version, sequence, notes, mandatory, bundle_size } -// } -// opts.devMode: true=按 build sequence 判新, false=按版本号判新 (默认) -export async function checkOtaUpdate(opts) { - const devMode = !!(opts && opts.devMode) - if (!inTauri) { - return checkOtaUpdateWeb(devMode) - } - return checkOtaUpdateTauri(devMode) -} - -// 取"当前运行版本": 已应用 OTA 覆盖包则用覆盖包版本, 否则用内置包版本 -async function currentRunningVersion() { - try { - const v = await getOtaVersionInfo() - if (v?.active && v.version) return v.version - } catch { /* ignore */ } - return APP_VERSION -} - -// 版本号口径判新 (Tauri + Web 共用): 服务端 version 比当前运行版本大才算新 -async function checkByVersion() { - const [manifest, current] = await Promise.all([fetchOtaManifest(), currentRunningVersion()]) - if (!manifest || !manifest.version) { - return { available: false, reason: 'no_package', info: null } - } - if (compareVersion(manifest.version, current) <= 0) { - return { available: false, reason: 'same_version', info: manifest } - } - return { - available: true, - reason: 'ok', - info: { - version: manifest.version, - sequence: manifest.sequence, - notes: manifest.notes || '', - mandatory: !!manifest.mandatory, - bundle_size: manifest.bundle_size || 0, - }, - } -} - -async function checkOtaUpdateTauri(devMode) { - // 开发口径: sequence 判新, 直接走插件 (插件内部用当前 sequence 请求 /api/ota/check/:seq) - if (devMode) { - const { checkUpdate } = await import('tauri-plugin-hotswap-api') - const r = await checkUpdate() - if (!r.available) { - return { available: false, reason: 'no_package', info: r } - } - return { - available: true, - reason: 'ok', - info: { - version: r.version, - sequence: r.sequence, - notes: r.notes, - mandatory: r.mandatory, - bundle_size: r.bundle_size, - }, - } - } - // 生产口径: 版本号判新 - return checkByVersion() -} - -async function checkOtaUpdateWeb(devMode) { - // Web 端不能下载, 只能拿到版本号/构建号做比对, 提示用户刷新 - try { - const res = await fetch(resolveUrl('/api/ota/manifest-web'), { method: 'GET' }) - if (!res.ok) { - return { available: false, reason: 'unsupported', info: null } - } - const data = await res.json() - if (!data.available) return { available: false, reason: 'no_package', info: data } - // Web 端拿不到本地 sequence: - // 开发口径 (devMode) → 只要服务端版本与本地不同就算新 (近似 sequence 语义, 含回退) - // 生产口径 → 严格比较版本号大小, 只有更高版本才算新 - const cmp = compareVersion(data.version, APP_VERSION) - if (devMode ? cmp === 0 : cmp <= 0) { - return { available: false, reason: 'same_version', info: data } - } - return { - available: true, - reason: 'ok', - info: { - version: data.version, - notes: data.note || '', - // web 端没有强制/mandatory 等字段, 仅展示 - mandatory: false, - bundle_size: data.size || 0, - }, - } - } catch (e) { - console.warn('[ota] web check failed:', e) - return { available: false, reason: 'unsupported', info: null } - } -} - -// 下载并应用更新 (Tauri only). 调用方负责 UI 提示和错误处理 -// onProgress(received, total) 进度回调 -// 成功后必须 reload, 新资源才会生效 -export async function applyOtaUpdate(onProgress) { - if (!inTauri) { - throw new Error('OTA 升级仅在 Tauri 客户端可用, Web 端请直接刷新页面') - } - const mod = await import('tauri-plugin-hotswap-api') - - // 注册一次性进度监听 (applyUpdate 期间触发) - let unlisten = null - let unlistenLifecycle = null - if (onProgress) { - try { - unlisten = await mod.onDownloadProgress((p) => onProgress(p.downloaded, p.total)) - } catch (e) { - console.warn('[ota] onDownloadProgress 监听失败:', e) - } - } - - // 监听 lifecycle 事件, 捕获失败详情 (download-error / apply 失败时 error 字段带具体原因) - // applyUpdate() 抛出的错误往往只有泛化信息, 细节藏在 lifecycle 事件里 - let lifecycleError = '' - try { - unlistenLifecycle = await mod.onLifecycle((ev) => { - if (ev.event === 'download-error' || ev.event === 'apply' || ev.event === 'check-error') { - if (ev.error) { - lifecycleError = ev.error - console.error('[ota] lifecycle', ev.event, 'error:', ev.error) - } - } - }) - } catch (e) { - console.warn('[ota] onLifecycle 监听失败:', e) - } - - try { - const newVersion = await mod.applyUpdate() - return newVersion - } catch (e) { - // 把 lifecycle 里的详细错误拼进抛出的异常, 方便前端展示真实原因 - const base = e?.message || '升级失败' - const detail = lifecycleError ? ` (${lifecycleError})` : '' - const err = new Error(base + detail) - err.cause = e - throw err - } finally { - if (unlisten) { - try { unlisten() } catch { /* ignore */ } - } - if (unlistenLifecycle) { - try { unlistenLifecycle() } catch { /* ignore */ } - } - } -} - -// 回滚到内置 dist (Tauri only, 一般用于 OTA 出错时手动恢复) -export async function rollbackOta() { - if (!inTauri) throw new Error('仅 Tauri 客户端可用') - const mod = await import('tauri-plugin-hotswap-api') - return mod.rollback() -} - -// 取当前 OTA 状态 (Tauri only) -export async function getOtaVersionInfo() { - if (!inTauri) return null - try { - const mod = await import('tauri-plugin-hotswap-api') - return await mod.getVersionInfo() - } catch (e) { - console.warn('[ota] getVersionInfo failed:', e) - return null - } -} - -// reload 当前页 (新资源生效) -export function reloadApp() { - window.location.reload() -} +// 前端 OTA 封装 +// - Tauri 客户端: 直接用 tauri-plugin-hotswap-api (checkUpdate / applyUpdate / ...) +// - Web 浏览器: 只走我们自己的 /api/ota/manifest, 提示用户手动刷新 (Vite 资源指纹会失效) +// +// 设计原则: +// - Web 端只能"查 + 提示", 不能真下载应用, 避免误导 +// - Tauri 端走 hotswap 插件的官方 API, 签名校验/回滚/资源替换全交给插件 +import { getApiBase } from './client.js' +import { APP_VERSION, BUILD_ID } from '../version.js' + +// 判断是否在 Tauri WebView 内 (有 __TAURI_INTERNALS__ 即 Tauri 环境) +const inTauri = typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window + +// 拼接绝对 URL: 在 Tauri (VITE_API_BASE 有值) 时直连, 否则同源 +function resolveUrl(p) { + const base = getApiBase() + return base ? base + p : p +} + +// 当前版本信息 (前端展示用) +export function getCurrentVersionInfo() { + return { version: APP_VERSION, build: BUILD_ID } +} + +// 构建标识统一化: 任意来源 (YYYYMMDDHHmmss / seq- / 毫秒数) → '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) +// 失败/异常都不抛, 仅 console.warn +export function initOta() { + if (!inTauri) return + // 动态 import: web 模式根本不打这个 chunk + import('tauri-plugin-hotswap-api') + .then((mod) => mod.notifyReady()) + .then(() => console.log('[ota] notifyReady ok')) + .catch((e) => console.warn('[ota] notifyReady failed:', e)) +} + +// --------------------------------------------------------------------------- +// 判新模式 (V0.2) +// 「开发版本」开关 (我的页 OTA 组内, 默认关闭), 值持久化在设备端 localStorage: +// 开关 关闭 (默认 / 生产口径): 用**版本号**判新 —— 取服务端 manifest 的 version, +// 与当前运行版本按数字字段逐段比较大小 (0.2.0 > 0.1.0)。 +// 这样即使某个旧版本分支被重新构建 (sequence 变大但版本号更小), 也不会把新设备拉回旧版。 +// 开关 打开 (开发口径): 用 **build sequence** 判新 —— 直接走插件 checkUpdate(), +// 只要服务端 sequence 比本地大就算新, 每次提交都能推到设备上, 便于联调。 +// --------------------------------------------------------------------------- +const OTA_DEV_MODE_KEY = 'iboard_ota_dev_mode' + +export function getOtaDevMode() { + try { + return localStorage.getItem(OTA_DEV_MODE_KEY) === '1' + } catch { /* ignore */ } + return false +} + +export function setOtaDevMode(v) { + try { localStorage.setItem(OTA_DEV_MODE_KEY, v ? '1' : '0') } catch { /* ignore */ } +} + +// 版本号 → 数字字段数组 / 比较函数 (纯函数放在 utils, 便于单测) +import { compareVersion, parseVersionFields } from '../utils/version.js' + +export { compareVersion, parseVersionFields } + +// 拉服务端最新 manifest (不分平台) +// /api/ota/check/0: current_sequence 传 0, 只要有 OTA 包就一定返回 200 + manifest +export async function fetchOtaManifest() { + try { + const res = await fetch(resolveUrl('/api/ota/check/0'), { method: 'GET' }) + if (res.status === 204) return null + if (!res.ok) return null + return await res.json() + } catch (e) { + console.warn('[ota] manifest 拉取失败:', e) + return null + } +} + +// 检查更新 +// 返回统一结构, 调用方不关心是 Tauri 还是 web: +// { +// available: boolean, +// reason: 'no_package' | 'same_version' | 'ota_disabled' | 'unsupported' | 'ok', +// info: { version, sequence, notes, mandatory, bundle_size } +// } +// opts.devMode: true=按 build sequence 判新, false=按版本号判新 (默认) +export async function checkOtaUpdate(opts) { + const devMode = !!(opts && opts.devMode) + if (!inTauri) { + return checkOtaUpdateWeb(devMode) + } + return checkOtaUpdateTauri(devMode) +} + +// 取"当前运行版本": 已应用 OTA 覆盖包则用覆盖包版本, 否则用内置包版本 +async function currentRunningVersion() { + try { + const v = await getOtaVersionInfo() + if (v?.active && v.version) return v.version + } catch { /* ignore */ } + return APP_VERSION +} + +// 版本号口径判新 (Tauri + Web 共用): 服务端 version 比当前运行版本大才算新 +async function checkByVersion() { + const [manifest, current] = await Promise.all([fetchOtaManifest(), currentRunningVersion()]) + if (!manifest || !manifest.version) { + return { available: false, reason: 'no_package', info: null } + } + if (compareVersion(manifest.version, current) <= 0) { + return { available: false, reason: 'same_version', info: manifest } + } + return { + available: true, + reason: 'ok', + info: { + version: manifest.version, + sequence: manifest.sequence, + build: manifest.build_id || fmtBuildId(manifest.sequence), + notes: manifest.notes || '', + commit: manifest.commit || '', + mandatory: !!manifest.mandatory, + bundle_size: manifest.bundle_size || 0, + }, + } +} + +async function checkOtaUpdateTauri(devMode) { + // 开发口径: sequence 判新, 直接走插件 (插件内部用当前 sequence 请求 /api/ota/check/:seq) + if (devMode) { + const { checkUpdate } = await import('tauri-plugin-hotswap-api') + const r = await checkUpdate() + if (!r.available) { + return { available: false, reason: 'no_package', info: r } + } + return { + available: true, + reason: 'ok', + info: { + version: r.version, + sequence: r.sequence, + build: fmtBuildId(r.sequence), + notes: r.notes, + mandatory: r.mandatory, + bundle_size: r.bundle_size, + }, + } + } + // 生产口径: 版本号判新 + return checkByVersion() +} + +async function checkOtaUpdateWeb(devMode) { + // Web 端不能下载, 只能拿到版本号/构建号做比对, 提示用户刷新 + try { + const res = await fetch(resolveUrl('/api/ota/manifest-web'), { method: 'GET' }) + if (!res.ok) { + return { available: false, reason: 'unsupported', info: null } + } + const data = await res.json() + if (!data.available) return { available: false, reason: 'no_package', info: data } + // Web 端拿不到本地 sequence: + // 开发口径 (devMode) → 只要服务端版本与本地不同就算新 (近似 sequence 语义, 含回退) + // 生产口径 → 严格比较版本号大小, 只有更高版本才算新 + const cmp = compareVersion(data.version, APP_VERSION) + if (devMode ? cmp === 0 : cmp <= 0) { + return { available: false, reason: 'same_version', info: data } + } + return { + available: true, + reason: 'ok', + info: { + version: data.version, + sequence: data.sequence, + build: fmtBuildId(data.build_id || data.sequence), + notes: data.note || '', + commit: data.commit || '', + // web 端没有强制/mandatory 等字段, 仅展示 + mandatory: false, + bundle_size: data.size || 0, + }, + } + } catch (e) { + console.warn('[ota] web check failed:', e) + return { available: false, reason: 'unsupported', info: null } + } +} + +// 下载并应用更新 (Tauri only). 调用方负责 UI 提示和错误处理 +// onProgress(received, total) 进度回调 +// 成功后必须 reload, 新资源才会生效 +export async function applyOtaUpdate(onProgress) { + if (!inTauri) { + throw new Error('OTA 升级仅在 Tauri 客户端可用, Web 端请直接刷新页面') + } + const mod = await import('tauri-plugin-hotswap-api') + + // 注册一次性进度监听 (applyUpdate 期间触发) + let unlisten = null + let unlistenLifecycle = null + if (onProgress) { + try { + unlisten = await mod.onDownloadProgress((p) => onProgress(p.downloaded, p.total)) + } catch (e) { + console.warn('[ota] onDownloadProgress 监听失败:', e) + } + } + + // 监听 lifecycle 事件, 捕获失败详情 (download-error / apply 失败时 error 字段带具体原因) + // applyUpdate() 抛出的错误往往只有泛化信息, 细节藏在 lifecycle 事件里 + let lifecycleError = '' + try { + unlistenLifecycle = await mod.onLifecycle((ev) => { + if (ev.event === 'download-error' || ev.event === 'apply' || ev.event === 'check-error') { + if (ev.error) { + lifecycleError = ev.error + console.error('[ota] lifecycle', ev.event, 'error:', ev.error) + } + } + }) + } catch (e) { + console.warn('[ota] onLifecycle 监听失败:', e) + } + + try { + const newVersion = await mod.applyUpdate() + return newVersion + } catch (e) { + // 把 lifecycle 里的详细错误拼进抛出的异常, 方便前端展示真实原因 + const base = e?.message || '升级失败' + const detail = lifecycleError ? ` (${lifecycleError})` : '' + const err = new Error(base + detail) + err.cause = e + throw err + } finally { + if (unlisten) { + try { unlisten() } catch { /* ignore */ } + } + if (unlistenLifecycle) { + try { unlistenLifecycle() } catch { /* ignore */ } + } + } +} + +// 回滚到内置 dist (Tauri only, 一般用于 OTA 出错时手动恢复) +export async function rollbackOta() { + if (!inTauri) throw new Error('仅 Tauri 客户端可用') + const mod = await import('tauri-plugin-hotswap-api') + return mod.rollback() +} + +// 取当前 OTA 状态 (Tauri only) +export async function getOtaVersionInfo() { + if (!inTauri) return null + try { + const mod = await import('tauri-plugin-hotswap-api') + return await mod.getVersionInfo() + } catch (e) { + console.warn('[ota] getVersionInfo failed:', e) + return null + } +} + +// reload 当前页 (新资源生效) +export function reloadApp() { + window.location.reload() +} diff --git a/src/api/task.js b/src/api/task.js index b4b133f..1c88765 100644 --- a/src/api/task.js +++ b/src/api/task.js @@ -35,6 +35,7 @@ export const taskApi = { create: (projectId, body) => api(`/projects/${projectId}/tasks`, { method: 'POST', body }), detail: (id) => api(`/tasks/${id}`), 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' }), copy: (id, projectId) => api(`/tasks/${id}/copy`, { method: 'POST', body: { projectId } }), } diff --git a/src/components/checkin/useHabitDrag.js b/src/components/checkin/useHabitDrag.js new file mode 100644 index 0000000..e21a1b2 --- /dev/null +++ b/src/components/checkin/useHabitDrag.js @@ -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 } +} diff --git a/src/components/task/Avatar.jsx b/src/components/task/Avatar.jsx new file mode 100644 index 0000000..8575fcd --- /dev/null +++ b/src/components/task/Avatar.jsx @@ -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 ( + + {name ? name.slice(0, 1) : '·'} + + ) +} diff --git a/src/components/task/CopyTaskPopup.jsx b/src/components/task/CopyTaskPopup.jsx index 93ff7ef..a3ea884 100644 --- a/src/components/task/CopyTaskPopup.jsx +++ b/src/components/task/CopyTaskPopup.jsx @@ -38,7 +38,10 @@ export default function CopyTaskPopup({ task, visible, onClose, onDone }) {
跨项目拷贝
「{task.title}」→ 选择目标项目;源任务保持不变
diff --git a/src/components/task/RecentProjects.jsx b/src/components/task/RecentProjects.jsx index 93551b0..09ad746 100644 --- a/src/components/task/RecentProjects.jsx +++ b/src/components/task/RecentProjects.jsx @@ -1,12 +1,11 @@ import { useEffect, useMemo, useRef, useState } from 'react' import { getRecentIds, getRecentOrder, setRecentOrder } from '../../pages/task/prefs.js' +import { colorOfProject } from '../../pages/task/projectIcon.js' // 首屏最近项目条 (T-VW-08/10) // - 展示最近使用的 3 个项目 + 「更多项目」入口 // - 顺序: 手动排序(recentOrder)优先, 其次最近访问(recentIds), 兜底列表顺序 // - 长按卡片 300ms 进入拖拽态, 水平拖动实时换位, 松手持久化(设备端) -const COLORS = ['#1677ff', '#00b578', '#ff8f1f', '#a855f7', '#14b8a6', '#f43f9e'] -const colorOf = (id) => COLORS[Math.abs(id) % COLORS.length] const LONG_PRESS = 300 const MOVE_TOLERANCE = 8 @@ -123,8 +122,13 @@ export default function RecentProjects({ projects, currentId, onSelect, onMore } onMouseDown={(ev) => beginPress(i, ev)} onClick={() => { if (!active.current && dragIdx < 0) onSelect?.(p.id) }} > -
- {(p.name || '?').slice(0, 1)} +
+ {p.icon ? '' : (p.name || '?').slice(0, 1)}
{p.name}
{p.taskCount ?? 0} 个任务
diff --git a/src/components/task/TaskCard.jsx b/src/components/task/TaskCard.jsx index 23a9ecb..889e02b 100644 --- a/src/components/task/TaskCard.jsx +++ b/src/components/task/TaskCard.jsx @@ -1,4 +1,6 @@ import StatusDot from './StatusDot.jsx' +import Avatar from './Avatar.jsx' +import { kindColor } from '../../pages/task/kind.js' // 看板卡片 (T-UI-02): 轻量化 // - 标题 + 状态点 (T-ST-09) @@ -20,44 +22,59 @@ export default function TaskCard({ onClick={onClick} {...(bindDrag ? bindDrag(task) : {})} > -
- {task.title} + {/* 左侧竖排: 状态点 + ⋯ */} +
{ e.stopPropagation(); onToggle?.(task) }} + onPointerDown={(e) => e.stopPropagation()} title="点按切换完成" > -
- -
- - {task.assignee?.name || '未指派'} - - {task.members?.length > 0 && ( - + {task.members.map((m) => m.name).join('、')} - )} {onMore && ( { e.stopPropagation(); onMore(task) }} + onPointerDown={(e) => e.stopPropagation()} >⋯ )}
- {shown.map((c) => ( -
{ e.stopPropagation(); onToggleSub?.(c) }} - > - - {c.title} - +
+
+ {task.title}
- ))} - {rest > 0 &&
+{rest} 个子任务
} + +
+ {task.markDate && ( + {task.markDate.slice(5, 10).replace('-', '/')} + )} + {task.kind && ( + {task.kind} + )} + + + {task.members?.length > 0 && ( + + {task.members.map((m) => m.name).join('、')} + )} + +
+ + {shown.map((c) => ( +
{ e.stopPropagation(); onToggleSub?.(c) }} + onPointerDown={(e) => e.stopPropagation()} + > + + {c.title} + +
+ ))} + {rest > 0 &&
+{rest} 个子任务
} +
) } diff --git a/src/components/task/TaskRow.jsx b/src/components/task/TaskRow.jsx index 48dc3ba..3c342a1 100644 --- a/src/components/task/TaskRow.jsx +++ b/src/components/task/TaskRow.jsx @@ -1,49 +1,37 @@ -import { useEffect, useRef } from 'react' 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) -// 长按 500ms → onLongPress (移动到列菜单); 点击其他区域 → onClick (编辑) -const LONG_PRESS = 500 - +// 点击行体 → 打开详情; 长按 400ms 后左右拖动换列 (bindDrag 来自 useRowDrag) +// 子任务与普通任务同构: 标题一行 + 状态元信息一行 (字体略小), 不显示分组标签 +// 复选框 / ⋯ 是独立控件: pointerdown 不冒泡, 不发起拖动, +// 避免长按行后抬手时合成的 click 落在控件上误触状态切换 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' return ( <>
{ if (!moved.current) onClick?.(task) }} - onTouchStart={startPress} - onTouchMove={markMoved} - onTouchEnd={cancelPress} - onMouseDown={startPress} - onMouseUp={cancelPress} + onClick={onClick} + {...(!indent && bindDrag ? bindDrag(task) : {})} > + {!indent && bindDragHandle && ( + ⠿ + )}
{ e.stopPropagation(); onToggle?.(task) }} + onPointerDown={(e) => e.stopPropagation()} > @@ -51,28 +39,37 @@ export default function TaskRow({
{indent ? ( - <> +
{task.title} - - +
+ + {statusLabel(task.status)} +
+
) : ( <>
{task.title}
+ {/* 无类型/成员也保留该行, 与有标签的行等高 */}
- - {task.status === 'done' ? '完成' : task.status === 'doing' ? '执行中' : '未开始'} - {task.column?.name && {task.column.name}} - - {task.assignee?.name || '未指派'} - + {task.markDate && ( + {task.markDate.slice(5, 10).replace('-', '/')} + )} + {task.kind && ( + {task.kind} + )} {task.members?.length > 0 && ( + {task.members.map((m) => m.name).join('、')} )}
- { e.stopPropagation(); onMore?.(task) }}>⋯ + { e.stopPropagation(); onMore?.(task) }} + onPointerDown={(e) => e.stopPropagation()} + >⋯ +
)} diff --git a/src/components/task/UserPicker.jsx b/src/components/task/UserPicker.jsx index 0ef053d..7eb789d 100644 --- a/src/components/task/UserPicker.jsx +++ b/src/components/task/UserPicker.jsx @@ -34,7 +34,7 @@ export default function UserPicker({ } return ( - +
{title}
仅可选择当前项目的成员
diff --git a/src/components/task/useColumnDrag.js b/src/components/task/useColumnDrag.js index 7403804..58d477f 100644 --- a/src/components/task/useColumnDrag.js +++ b/src/components/task/useColumnDrag.js @@ -1,18 +1,44 @@ import { useCallback, useEffect, useRef, useState } from 'react' -// 看板卡片列拖动 (重点设计 §7.1, 2026-10-24 重做) -// 方案要点 (替代旧版"手指跟随浮层 + 落点即子任务"的不可靠实现): +// 看板卡片列拖动 (重点设计 §7.1) +// 方案要点: // 1. Pointer Events + setPointerCapture —— 触摸/鼠标统一, 捕获后不丢事件 -// 2. 长按 400ms 激活 (先给滚动让路: 激活前移动 >10px 视为滚动并取消) -// 3. 激活后卡片原位抬起 (不跟随手指, 无浮层/无任务名), touch-action 由捕获保证 +// 2. 长按 400ms 激活 (激活前移动 >10px 视为滚动, 取消本次拖动) +// 3. 激活后卡片原位抬起 (不跟随手指, 无浮层/无任务名) // 4. 拖动中持续 elementFromPoint 命中 [data-column-id] 高亮目标列; -// 指针靠近容器左右边缘时自动横向滚动, 可拖到屏幕外的列 -// 5. 松手/取消(pointercancel 也要处理) → 落到目标列(≠原列)才提交; -// 拖到卡片上不再转子任务 —— 统一走详情页「转为子任务」 +// 指针靠近容器左右边缘时 rAF 自动横滚, 可拖到屏幕外的列 +// 5. 松手落到目标列 (≠原列) 才提交; pointercancel (浏览器接管手势, +// 如触发纵向滚动) 只放弃不提交 +// 6. 防误触: 卡片禁长按系统菜单(contextmenu); 拖动结束后 350ms 内的 +// click 视为拖动余波, 不打开详情 (justEnded) export default function useColumnDrag({ onDrop, scrollSelector = '.board-wrap' } = {}) { const [dragTask, setDragTask] = useState(null) // 拖动中的任务 (视觉态) const [overCol, setOverCol] = useState(null) // 高亮的目标列 id - const st = useRef({ timer: null, active: false, startX: 0, startY: 0, task: null, autoScroll: 0, raf: 0 }) + const 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 = () => { st.current.autoScroll = 0 @@ -22,6 +48,8 @@ export default function useColumnDrag({ onDrop, scrollSelector = '.board-wrap' } const cleanup = useCallback(() => { if (st.current.timer) { clearTimeout(st.current.timer); st.current.timer = null } st.current.active = false + st.current.srcEl = null + if (st.current.ghost) { st.current.ghost.remove(); st.current.ghost = null } stopAutoScroll() setDragTask(null) setOverCol(null) @@ -29,6 +57,9 @@ export default function useColumnDrag({ onDrop, scrollSelector = '.board-wrap' } useEffect(() => () => cleanup(), [cleanup]) + /** 拖动刚结束 (350ms 内): 抑制松手合成的 click 误开详情页 */ + const justEnded = useCallback(() => Date.now() - lastEnd.current < 350, []) + const hitColumn = (x, y) => { const el = document.elementFromPoint(x, y) const colEl = el?.closest?.('[data-column-id]') @@ -54,17 +85,6 @@ export default function useColumnDrag({ onDrop, scrollSelector = '.board-wrap' } 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 s = st.current if (!s.active) { @@ -74,6 +94,7 @@ export default function useColumnDrag({ onDrop, scrollSelector = '.board-wrap' } return } ev.preventDefault() + moveGhost(ev.clientX, ev.clientY) const col = hitColumn(ev.clientX, ev.clientY) setOverCol(col) autoScroll(document.querySelector(scrollSelector), ev.clientX) @@ -82,33 +103,78 @@ export default function useColumnDrag({ onDrop, scrollSelector = '.board-wrap' } const onPointerUp = (ev) => { const s = st.current if (s.active) { + lastEnd.current = Date.now() const col = hitColumn(ev.clientX, ev.clientY) - if (col && s.task && col !== s.task.columnId) onDrop?.(s.task, col) + if (col && s.task && col !== s.task.columnId) onDropRef.current?.(s.task, col) } 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 */ const bind = useCallback((task) => ({ onPointerDown: (ev) => { - // 捕获前先让事件正常冒泡(点击等); 只做长按计时 - onPointerDown(task)(ev) - // 捕获: 后续 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) + 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 }), []) - 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 } } diff --git a/src/components/task/useRowDrag.js b/src/components/task/useRowDrag.js new file mode 100644 index 0000000..c47943c --- /dev/null +++ b/src/components/task/useRowDrag.js @@ -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 } +} diff --git a/src/hooks/useKeyboardInset.js b/src/hooks/useKeyboardInset.js new file mode 100644 index 0000000..096bc21 --- /dev/null +++ b/src/hooks/useKeyboardInset.js @@ -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 +} diff --git a/src/hooks/useKeyboardInsetWatcher.js b/src/hooks/useKeyboardInsetWatcher.js deleted file mode 100644 index 71bf9a3..0000000 --- a/src/hooks/useKeyboardInsetWatcher.js +++ /dev/null @@ -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') - } - }, []) -} diff --git a/src/pages/MePage.jsx b/src/pages/MePage.jsx index f67c8bd..f7a420b 100644 --- a/src/pages/MePage.jsx +++ b/src/pages/MePage.jsx @@ -1,247 +1,262 @@ -import { useEffect, useState } from 'react' -import { useNavigate } from 'react-router-dom' -import { Avatar, Button, List, ProgressBar, Switch, Toast } from 'antd-mobile' -import { useAuth } from '../auth/AuthContext.jsx' -import { APP_VERSION, BUILD_ID } from '../version.js' -import { - applyOtaUpdate, - checkOtaUpdate, - getCurrentVersionInfo, - getOtaDevMode, - getOtaVersionInfo, - reloadApp, - rollbackOta, - setOtaDevMode, -} from '../api/ota.js' - -// "我" 页: 顶部 NavBar + 用户头像卡片 + 信息列表 + OTA 升级区 -// OTA 区: -// - 显示当前 APP_VERSION (前端包, 对应内置 dist) -// - 显示当前 hotswap 状态 (是否已应用覆盖包) -// - [检查更新]: 走 checkOtaUpdate() -// - [立即升级]: 走 applyOtaUpdate(), 完成后 reload -// - [回滚]: 清除覆盖包, 回到内置 dist -export default function MePage() { - const { user, logout } = useAuth() - const navigate = useNavigate() - - // 顶部安全区与「我」页蓝色渐变头部无缝衔接: 给 body 打标记, - // 由 CSS 让 .app-safe-top 也使用同一条渐变, 避免出现一条白边/色块把蓝色割断. - // 离开本页时移除标记, 其他页恢复默认底色. - useEffect(() => { - document.body.classList.add('me-safe-top') - return () => { document.body.classList.remove('me-safe-top') } - }, []) - - // OTA 状态 - const [otaState, setOtaState] = useState('idle') // idle | checking | available | none | downloading | ready | error - const [otaInfo, setOtaInfo] = useState(null) // 检查结果中的 info - const [otaVersion, setOtaVersion] = useState(null) // hotswap 当前激活的覆盖包版本 - const [progress, setProgress] = useState({ received: 0, total: 0 }) - const [error, setError] = useState('') - // 判新模式: false(默认)=版本号比较, true=build sequence 比较; 值持久化在设备端 - const [devMode, setDevMode] = useState(() => getOtaDevMode()) - - // 进入页面时拉一次 hotswap 状态 (Tauri 内才需要, web 下 null 也无所谓) - useEffect(() => { - let cancelled = false - getOtaVersionInfo().then((v) => { - if (!cancelled) setOtaVersion(v) - }) - return () => { cancelled = true } - }, []) - - async function onCheck() { - setError('') - setOtaState('checking') - try { - const r = await checkOtaUpdate({ devMode }) - if (r.available) { - setOtaInfo(r.info) - setOtaState('available') - Toast.show({ icon: 'success', content: '发现新版本 v' + (r.info?.version || '') }) - } else { - setOtaInfo(null) - setOtaState('none') - if (r.reason === 'same_version') { - Toast.show({ content: '已是最新版本' }) - } else if (r.reason === 'no_package') { - Toast.show({ content: '当前没有可用更新' }) - } else { - Toast.show({ content: '当前没有可用更新' }) - } - } - } catch (e) { - setError(e?.message || '检查失败') - setOtaState('error') - Toast.show({ icon: 'fail', content: '检查失败: ' + (e?.message || '') }) - } - } - - async function onApply() { - setError('') - setOtaState('downloading') - setProgress({ received: 0, total: 0 }) - try { - const newVer = await applyOtaUpdate((received, total) => { - setProgress({ received, total }) - }) - Toast.show({ icon: 'success', content: '已下载 v' + newVer + ', 即将重启' }) - setOtaState('ready') - // 给用户 1s 看提示, 然后 reload - setTimeout(() => reloadApp(), 1000) - } catch (e) { - setError(e?.message || '升级失败') - setOtaState('error') - Toast.show({ icon: 'fail', content: '升级失败: ' + (e?.message || '') }) - } - } - - async function onRollback() { - if (!window.confirm('确认回滚到内置版本?')) return - try { - await rollbackOta() - Toast.show({ icon: 'success', content: '已回滚, 正在重启' }) - setTimeout(() => reloadApp(), 800) - } catch (e) { - Toast.show({ icon: 'fail', content: '回滚失败: ' + (e?.message || '') }) - } - } - - async function onLogout() { - const confirmed = window.confirm('确定退出登录?') - if (!confirmed) return - await logout() - Toast.show({ icon: 'success', content: '已退出' }) - navigate('/login', { replace: true }) - } - - const cur = getCurrentVersionInfo() - const isTauri = typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window - const showProgress = otaState === 'downloading' && progress.total > 0 - const progressPct = showProgress ? Math.min(100, Math.round((progress.received / progress.total) * 100)) : 0 - - return ( -
-
- - {(user?.name || '?').slice(0, 1).toUpperCase()} - -
{user?.name || '用户'}
-
{user?.email}
-
- - {/* OTA 升级区 */} - - 当前版本 - - 运行资源 - - {/* 判新口径开关: 默认关(版本号比较), 打开后按 build sequence 比较 */} - { - setDevMode(v) - setOtaDevMode(v) - // 切换口径后旧检查结果失效, 重置避免误导 - setOtaState('idle') - setOtaInfo(null) - setError('') - Toast.show({ content: v ? '开发版本: 按构建号判新' : '正式口径: 按版本号判新' }) - }} - /> - } - description={ - devMode - ? '按 build sequence 判新:每次构建都算新版本,便于开发联调' - : '按版本号判新:仅当服务端版本号更高时才提示更新' - } - > - 开发版本 - - -
- - - {isTauri && otaVersion?.active && ( - - )} -
-
- {otaState === 'available' && otaInfo && ( - - 新版本 - {otaInfo.bundle_size ? ` (${(otaInfo.bundle_size / 1024).toFixed(0)} KB)` : ''} - - )} - {otaState === 'downloading' && ( - -
-
- 正在下载 {showProgress ? progressPct + '%' : ''} - {' '}({(progress.received / 1024).toFixed(0)} KB - {progress.total ? ' / ' + (progress.total / 1024).toFixed(0) + ' KB' : ''}) -
- -
-
- )} - {otaInfo?.notes && ( - 发布说明 - )} - {error && ( - - {error} - - )} - {!isTauri && ( - 提示 - )} -
- - - 用户 ID - - 注册时间 - - - 退出登录 - - -
v{APP_VERSION} · build {BUILD_ID}
-
- ) -} +import { useEffect, useState } from 'react' +import { useNavigate } from 'react-router-dom' +import { Avatar, Button, List, ProgressBar, Switch, Toast } from 'antd-mobile' +import { useAuth } from '../auth/AuthContext.jsx' +import { + applyOtaUpdate, + checkOtaUpdate, + fmtBuildId, + getCurrentVersionInfo, + getOtaDevMode, + getOtaVersionInfo, + reloadApp, + rollbackOta, + setOtaDevMode, +} from '../api/ota.js' + +const inTauri = typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window + +// "我" 页: 顶部 NavBar + 用户头像卡片 + 信息列表 + OTA 升级区 +// OTA 区: +// - 显示当前 APP_VERSION (前端包, 对应内置 dist) +// - 显示当前 hotswap 状态 (是否已应用覆盖包) +// - [检查更新]: 走 checkOtaUpdate() +// - [立即升级]: 走 applyOtaUpdate(), 完成后 reload +// - [回滚]: 清除覆盖包, 回到内置 dist +export default function MePage() { + const { user, logout } = useAuth() + const navigate = useNavigate() + + // 顶部安全区与「我」页蓝色渐变头部无缝衔接: 给 body 打标记, + // 由 CSS 让 .app-safe-top 也使用同一条渐变, 避免出现一条白边/色块把蓝色割断. + // 离开本页时移除标记, 其他页恢复默认底色. + useEffect(() => { + document.body.classList.add('me-safe-top') + return () => { document.body.classList.remove('me-safe-top') } + }, []) + + // OTA 状态 + const [otaState, setOtaState] = useState('idle') // idle | checking | available | none | downloading | ready | error + const [otaInfo, setOtaInfo] = useState(null) // 检查结果中的 info + const [otaVersion, setOtaVersion] = useState(null) // hotswap 当前激活的覆盖包版本 + const [progress, setProgress] = useState({ received: 0, total: 0 }) + const [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('') + // 判新模式: false(默认)=版本号比较, true=build sequence 比较; 值持久化在设备端 + const [devMode, setDevMode] = useState(() => getOtaDevMode()) + + // 进入页面时拉一次 hotswap 状态 (Tauri 内才需要, web 下 null 也无所谓) + useEffect(() => { + let cancelled = false + getOtaVersionInfo().then((v) => { + if (!cancelled) setOtaVersion(v) + }) + return () => { cancelled = true } + }, []) + + async function onCheck() { + setError('') + setOtaState('checking') + try { + const r = await checkOtaUpdate({ devMode }) + if (r.available) { + setOtaInfo(r.info) + setOtaState('available') + Toast.show({ icon: 'success', content: '发现新版本 v' + (r.info?.version || '') }) + } else { + setOtaInfo(null) + setOtaState('none') + if (r.reason === 'same_version') { + Toast.show({ content: '已是最新版本' }) + } else if (r.reason === 'no_package') { + Toast.show({ content: '当前没有可用更新' }) + } else { + Toast.show({ content: '当前没有可用更新' }) + } + } + } catch (e) { + setError(e?.message || '检查失败') + setOtaState('error') + Toast.show({ icon: 'fail', content: '检查失败: ' + (e?.message || '') }) + } + } + + async function onApply() { + setError('') + setOtaState('downloading') + setProgress({ received: 0, total: 0 }) + try { + const newVer = await applyOtaUpdate((received, total) => { + setProgress({ received, total }) + }) + Toast.show({ icon: 'success', content: '已下载 v' + newVer + ', 即将重启' }) + setOtaState('ready') + // 给用户 1s 看提示, 然后 reload + setTimeout(() => reloadApp(), 1000) + } catch (e) { + setError(e?.message || '升级失败') + setOtaState('error') + Toast.show({ icon: 'fail', content: '升级失败: ' + (e?.message || '') }) + } + } + + async function onRollback() { + if (!window.confirm('确认回滚到内置版本?')) return + try { + await rollbackOta() + Toast.show({ icon: 'success', content: '已回滚, 正在重启' }) + setTimeout(() => reloadApp(), 800) + } catch (e) { + Toast.show({ icon: 'fail', content: '回滚失败: ' + (e?.message || '') }) + } + } + + async function onLogout() { + const confirmed = window.confirm('确定退出登录?') + if (!confirmed) return + await logout() + Toast.show({ icon: 'success', content: '已退出' }) + navigate('/login', { replace: true }) + } + + const cur = getCurrentVersionInfo() + const isTauri = typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window + const showProgress = otaState === 'downloading' && progress.total > 0 + const progressPct = showProgress ? Math.min(100, Math.round((progress.received / progress.total) * 100)) : 0 + + return ( +
+
+ + {(user?.name || '?').slice(0, 1).toUpperCase()} + +
{user?.name || '用户'}
+
{user?.email}
+
+ + {/* OTA 升级区 */} + + 当前版本 + + 运行资源 + + {/* 判新口径开关: 默认关(版本号比较), 打开后按 build sequence 比较 */} + { + setDevMode(v) + setOtaDevMode(v) + // 切换口径后旧检查结果失效, 重置避免误导 + setOtaState('idle') + setOtaInfo(null) + setError('') + Toast.show({ content: v ? '开发版本: 按构建号判新' : '正式口径: 按版本号判新' }) + }} + /> + } + description={ + devMode + ? '按 build sequence 判新:每次构建都算新版本,便于开发联调' + : '按版本号判新:仅当服务端版本号更高时才提示更新' + } + > + 开发版本 + + +
+ + + {isTauri && otaVersion?.active && ( + + )} +
+
+ {otaState === 'available' && otaInfo && ( + + 新版本 + {otaInfo.bundle_size ? ` (${(otaInfo.bundle_size / 1024).toFixed(0)} KB)` : ''} + + )} + {otaState === 'downloading' && ( + +
+
+ 正在下载 {showProgress ? progressPct + '%' : ''} + {' '}({(progress.received / 1024).toFixed(0)} KB + {progress.total ? ' / ' + (progress.total / 1024).toFixed(0) + ' KB' : ''}) +
+ +
+
+ )} + {otaInfo?.notes && ( + 发布说明 + )} + {otaInfo?.commit && ( + 提交 + )} + {error && ( + + {error} + + )} + {!isTauri && ( + 提示 + )} +
+ + + 用户 ID + + 注册时间 + + + 退出登录 + + +
外壳 v{shellVersion || '—'}
+
+ ) +} diff --git a/src/pages/checkin/HabitView.jsx b/src/pages/checkin/HabitView.jsx index cc9ca08..56eeab4 100644 --- a/src/pages/checkin/HabitView.jsx +++ b/src/pages/checkin/HabitView.jsx @@ -1,282 +1,338 @@ -import { useCallback, useEffect, useMemo, useState } from 'react' -import { - Button, Dialog, DotLoading, Empty, Input, Popup, Selector, Stepper, Toast, -} from 'antd-mobile' -import RingProgress, { Celebrate } from '../../components/RingProgress.jsx' -import { checkinApi } from '../../api/checkin.js' -import useRefreshOnVisible from '../../hooks/useRefreshOnVisible.js' -import { toDateStr, dayLabel } from './date.js' - -const PERIOD_OPTIONS = [ - { label: '每天', value: 'day' }, - { label: '每周', value: 'week' }, - { label: '每月', value: 'month' }, - { label: '每年', value: 'year' }, -] - -const COLORS = ['#1677ff', '#00b578', '#ff8f1f', '#ff3141', '#a855f7', '#14b8a6', '#f43f9e'] - -const EMPTY_FORM = { id: null, name: '', period: 'day', target: 1, color: COLORS[0], note: '' } - -// 打卡视图: 今日待办列表 + 环形进度 + 打卡/撤销 + 新建/编辑 -export default function HabitView({ date, onDateChange }) { - const [data, setData] = useState(null) // { today, date, items, summary } - const [err, setErr] = useState('') - const [bursts, setBursts] = useState({}) // { habitId: 触发计数 } - const [form, setForm] = useState(EMPTY_FORM) - const [showForm, setShowForm] = useState(false) - - const load = useCallback((silent) => { - if (!silent) setErr('') - checkinApi.habits(date) - .then((d) => setData(d)) - .catch((e) => { if (!silent) setErr(e.message) }) - }, [date]) - - useEffect(() => { load(false) }, [load]) - useRefreshOnVisible(() => load(true)) - - const items = useMemo(() => { - const list = data?.items || [] - // 未完成在前, 已完成在后 (F-CK-16 默认排序) - return [...list].sort((a, b) => Number(a.completed) - Number(b.completed) || a.id - b.id) - }, [data]) - - const fireBurst = (id) => setBursts((m) => ({ ...m, [id]: (m[id] || 0) + 1 })) - - // 乐观更新: 先本地 +1, 成功用服务端结果覆盖, 失败回滚 (F-SY-06/E-02) - async function doCheck(item) { - const before = data - const patch = (it) => { - const done = it.done + 1 - return { - ...it, - done, - remaining: Math.max(0, it.target - done), - percent: Math.min(1, done / it.target), - completed: done >= it.target, - over: Math.max(0, done - it.target), - countOnDate: (it.countOnDate || 0) + 1, - dayDone: (it.countOnDate || 0) + 1 >= it.expectedPerDay, - } - } - setData((d) => ({ ...d, items: d.items.map((x) => (x.id === item.id ? patch(x) : x)) })) - try { - const res = await checkinApi.check(item.id, date) - setData((d) => ({ ...d, items: d.items.map((x) => (x.id === item.id ? res.item : x)) })) - if (res.item.completed) fireBurst(item.id) - } catch (e) { - setData(before) - Toast.show({ content: e.message || '打卡失败' }) - } - } - - async function doUncheck(item) { - const before = data - setData((d) => ({ - ...d, - items: d.items.map((x) => { - if (x.id !== item.id) return x - const done = Math.max(0, x.done - 1) - return { - ...x, - done, - remaining: Math.max(0, x.target - done), - percent: Math.min(1, done / x.target), - completed: done >= x.target, - over: Math.max(0, done - x.target), - countOnDate: Math.max(0, (x.countOnDate || 0) - 1), - } - }), - })) - try { - const res = await checkinApi.uncheck(item.id, date) - setData((d) => ({ ...d, items: d.items.map((x) => (x.id === item.id ? res.item : x)) })) - } catch (e) { - setData(before) - Toast.show({ content: e.message || '撤销失败' }) - } - } - - async function submitForm() { - const name = String(form.name || '').trim() - if (!name) return Toast.show({ content: '请填写名称' }) - if (name.length > 20) return Toast.show({ content: '名称不能超过 20 个字' }) - const payload = { name, period: form.period, target: Number(form.target) || 1, color: form.color, note: form.note } - try { - if (form.id) await checkinApi.updateHabit(form.id, payload) - else await checkinApi.createHabit(payload) - setShowForm(false) - setForm(EMPTY_FORM) - load(true) - } catch (e) { - Toast.show({ content: e.message || '保存失败' }) - } - } - - async function removeHabit(item) { - const ok = await Dialog.confirm({ - content: `归档「${item.name}」?归档后不再出现在待办,历史记录保留。`, - confirmText: '归档', - cancelText: '取消', - }) - if (!ok) return - try { - await checkinApi.deleteHabit(item.id) - Toast.show({ content: '已归档' }) - load(true) - } catch (e) { - Toast.show({ content: e.message || '操作失败' }) - } - } - - const summary = data?.summary || { total: 0, done: 0 } - const isToday = data ? data.date === data.today : true - - return ( -
-
-
-
{dayLabel(date, data?.today || toDateStr())}
-
- 已完成 {summary.done} / {summary.total} - {!isToday && 非今天} -
-
-
- -
-
- - {err && ( -
- {err} - load(false)}>重试 -
- )} - - {data === null && !err &&
加载中...
} - - {data !== null && !err && items.length === 0 && ( -
- - -
- )} - -
- {items.map((item) => ( -
- doCheck(item)} - /> -
-
{item.name}
-
- {item.periodLabel} {item.target} 次 - {item.streak > 0 && 连续 {item.streak}} -
- {item.note &&
{item.note}
} -
- {item.periodStart === item.periodEnd - ? '今日' - : `${item.periodStart.slice(5)} ~ ${item.periodEnd.slice(5)}`} - {item.over > 0 && 超额 {item.over}} -
-
-
- -
-
- {item.done > 0 && ( - doUncheck(item)}>撤销 - )} - { - setForm({ id: item.id, name: item.name, period: item.period, target: item.target, color: item.color, note: item.note }) - setShowForm(true) - }} - >编辑 - removeHabit(item)}>归档 -
- -
- ))} -
- - {/* 新建 / 编辑 */} - setShowForm(false)} - bodyStyle={{ borderTopLeftRadius: 12, borderTopRightRadius: 12, paddingBottom: 16 }} - > -
-
{form.id ? '编辑打卡项' : '新建打卡项'}
-
- - setForm((f) => ({ ...f, name: v }))} - /> -
-
- - setForm((f) => ({ ...f, period: v[0] }))} - /> -
-
- - setForm((f) => ({ ...f, target: v }))} /> -
-
- -
- {COLORS.map((c) => ( - setForm((f) => ({ ...f, color: c }))} - /> - ))} -
-
-
- - setForm((f) => ({ ...f, note: v }))} - /> -
-
- - -
-
-
-
- ) -} +import { useCallback, useEffect, useMemo, useState } from 'react' +import { + Button, Dialog, DotLoading, Empty, Input, Popup, Selector, Stepper, Toast, +} from 'antd-mobile' +import RingProgress, { Celebrate } from '../../components/RingProgress.jsx' +import useHabitDrag from '../../components/checkin/useHabitDrag.js' +import { checkinApi } from '../../api/checkin.js' +import useRefreshOnVisible from '../../hooks/useRefreshOnVisible.js' +import { toDateStr, dayLabel } from './date.js' + +const PERIOD_OPTIONS = [ + { label: '每天', value: 'day' }, + { label: '每周', value: 'week' }, + { label: '每月', value: 'month' }, + { label: '每年', value: 'year' }, +] + +const COLORS = ['#1677ff', '#00b578', '#ff8f1f', '#ff3141', '#a855f7', '#14b8a6', '#f43f9e'] + +const EMPTY_FORM = { id: null, name: '', period: 'day', target: 1, color: COLORS[0], note: '' } + +// 打卡视图: 今日待办列表 + 环形进度 + 打卡/撤销 + 新建/编辑 +export default function HabitView({ date, onDateChange }) { + const [data, setData] = useState(null) // { today, date, items, summary } + const [err, setErr] = useState('') + const [bursts, setBursts] = useState({}) // { habitId: 触发计数 } + const [form, setForm] = useState(EMPTY_FORM) + const [showForm, setShowForm] = useState(false) + + const load = useCallback((silent) => { + if (!silent) setErr('') + checkinApi.habits(date) + .then((d) => setData(d)) + .catch((e) => { if (!silent) setErr(e.message) }) + }, [date]) + + useEffect(() => { load(false) }, [load]) + useRefreshOnVisible(() => load(true)) + + const items = useMemo(() => { + const list = data?.items || [] + // 手动排序优先 (sortOrder), 拖动排序后覆盖 F-CK-16 的"未完成在前" + return [...list].sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0) || a.id - b.id) + }, [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 })) + + // 乐观更新: 先本地 +1, 成功用服务端结果覆盖, 失败回滚 (F-SY-06/E-02) + async function doCheck(item) { + const before = data + const patch = (it) => { + const done = it.done + 1 + return { + ...it, + done, + remaining: Math.max(0, it.target - done), + percent: Math.min(1, done / it.target), + completed: done >= it.target, + over: Math.max(0, done - it.target), + countOnDate: (it.countOnDate || 0) + 1, + dayDone: (it.countOnDate || 0) + 1 >= it.expectedPerDay, + } + } + setData((d) => ({ ...d, items: d.items.map((x) => (x.id === item.id ? patch(x) : x)) })) + try { + const res = await checkinApi.check(item.id, date) + setData((d) => ({ ...d, items: d.items.map((x) => (x.id === item.id ? res.item : x)) })) + if (res.item.completed) fireBurst(item.id) + } catch (e) { + setData(before) + Toast.show({ content: e.message || '打卡失败' }) + } + } + + async function doUncheck(item) { + const before = data + setData((d) => ({ + ...d, + items: d.items.map((x) => { + if (x.id !== item.id) return x + const done = Math.max(0, x.done - 1) + return { + ...x, + done, + remaining: Math.max(0, x.target - done), + percent: Math.min(1, done / x.target), + completed: done >= x.target, + over: Math.max(0, done - x.target), + countOnDate: Math.max(0, (x.countOnDate || 0) - 1), + } + }), + })) + try { + const res = await checkinApi.uncheck(item.id, date) + setData((d) => ({ ...d, items: d.items.map((x) => (x.id === item.id ? res.item : x)) })) + } catch (e) { + setData(before) + Toast.show({ content: e.message || '撤销失败' }) + } + } + + async function submitForm() { + const name = String(form.name || '').trim() + if (!name) return Toast.show({ content: '请填写名称' }) + if (name.length > 20) return Toast.show({ content: '名称不能超过 20 个字' }) + const payload = { name, period: form.period, target: Number(form.target) || 1, color: form.color, note: form.note } + try { + if (form.id) await checkinApi.updateHabit(form.id, payload) + else await checkinApi.createHabit(payload) + setShowForm(false) + setForm(EMPTY_FORM) + load(true) + } catch (e) { + Toast.show({ content: e.message || '保存失败' }) + } + } + + async function removeHabit(item) { + const ok = await Dialog.confirm({ + content: `归档「${item.name}」?归档后不再出现在待办,历史记录保留。`, + confirmText: '归档', + cancelText: '取消', + }) + if (!ok) return + try { + await checkinApi.deleteHabit(item.id) + Toast.show({ content: '已归档' }) + load(true) + } catch (e) { + Toast.show({ content: e.message || '操作失败' }) + } + } + + const summary = data?.summary || { total: 0, done: 0 } + const isToday = data ? data.date === data.today : true + + return ( +
+
+
+
{dayLabel(date, data?.today || toDateStr())}
+
+ 已完成 {summary.done} / {summary.total} + {!isToday && 非今天} +
+
+
+ +
+
+ + {err && ( +
+ {err} + load(false)}>重试 +
+ )} + + {data === null && !err &&
加载中...
} + + {data !== null && !err && items.length === 0 && ( +
+ + +
+ )} + +
+ {groups.map((g) => ( +
+
+ {g.label} + {g.list.length} +
+ {g.list.map((item) => ( +
+ { if (!drag.justEnded()) doCheck(item) }} + /> +
+
{item.name}
+
+ {item.periodLabel} {item.target} 次 + {item.streak > 0 && 连续 {item.streak}} +
+ {item.note &&
{item.note}
} +
+ {item.periodStart === item.periodEnd + ? '今日' + : `${item.periodStart.slice(5)} ~ ${item.periodEnd.slice(5)}`} + {item.over > 0 && 超额 {item.over}} +
+
+
+ +
+
+ {item.done > 0 && ( + { if (!drag.justEnded()) doUncheck(item) }}>撤销 + )} + { + 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) + }} + >编辑 + { if (!drag.justEnded()) removeHabit(item) }}>归档 +
+ +
+ ))} +
+ ))} +
+ + {/* 新建 / 编辑 */} + setShowForm(false)} + bodyStyle={{ borderTopLeftRadius: 12, borderTopRightRadius: 12, paddingBottom: 16 }} + > +
+
{form.id ? '编辑打卡项' : '新建打卡项'}
+
+ + setForm((f) => ({ ...f, name: v }))} + /> +
+
+ + setForm((f) => ({ ...f, period: v[0] }))} + /> +
+
+ + setForm((f) => ({ ...f, target: v }))} /> +
+
+ +
+ {COLORS.map((c) => ( + setForm((f) => ({ ...f, color: c }))} + /> + ))} +
+
+
+ + setForm((f) => ({ ...f, note: v }))} + /> +
+
+ + +
+
+
+
+ ) +} diff --git a/src/pages/checkin/MergedView.jsx b/src/pages/checkin/MergedView.jsx index 884afe3..60a09ee 100644 --- a/src/pages/checkin/MergedView.jsx +++ b/src/pages/checkin/MergedView.jsx @@ -1,416 +1,461 @@ -// 打卡 · 日历与打卡合并视图 (V0.2.1 界面调整, 设计文档 §11.5) -// - 上: 月历 (月份切换 + 回到今天), 去掉「昨天/今天/明天」分段 -// - 下: 当日打卡项, 按 每天 / 每周 / 每月 分组 (去掉「每年」分组) -// - 卡片紧凑化: 内边距 8px, 环形 42px/环宽 5, 间距 7px -// - 撤销 / 编辑 / 归档 收进「⋯」底部面板 -// - 未来日期只读, 按钮灰显「计划」 -import { useCallback, useEffect, useMemo, useState } from 'react' -import { - Button, Dialog, DotLoading, Empty, Input, Popup, Selector, Stepper, Toast, -} from 'antd-mobile' -import RingProgress, { Celebrate } from '../../components/RingProgress.jsx' -import { checkinApi } from '../../api/checkin.js' -import useRefreshOnVisible from '../../hooks/useRefreshOnVisible.js' -import { - toDateStr, addDays, addMonths, monthGrid, weekdayLabels, dayLabel, fmtMonth, -} from './date.js' - -// 分组顺序: 每天 → 每周 → 每月 (去掉每年) -const GROUP_ORDER = ['day', 'week', 'month'] -const GROUP_LABEL = { day: '每天', week: '每周', month: '每月' } - -const PERIOD_OPTIONS = [ - { label: '每天', value: 'day' }, - { label: '每周', value: 'week' }, - { label: '每月', value: 'month' }, - { label: '每年', value: 'year' }, -] - -const COLORS = ['#1677ff', '#00b578', '#ff8f1f', '#ff3141', '#a855f7', '#14b8a6', '#f43f9e'] -const EMPTY_FORM = { id: null, name: '', period: 'day', target: 1, color: COLORS[0], note: '' } - -export default function MergedView({ date, onDateChange }) { - const today = toDateStr() - const [month, setMonth] = useState(() => date.slice(0, 7)) - const [cal, setCal] = useState(null) // 月历数据 { days, rate } - const [detail, setDetail] = useState(null) // 选中日详情 { today, date, items, summary, isFuture } - const [err, setErr] = useState('') - const [bursts, setBursts] = useState({}) - const [sheetItem, setSheetItem] = useState(null) // 「⋯」面板对应的打卡项 - const [form, setForm] = useState(EMPTY_FORM) - const [showForm, setShowForm] = useState(false) - - // ---------- 数据加载 ---------- - const loadCal = useCallback((silent) => { - if (!silent) setErr('') - checkinApi.calendar(month) - .then(setCal) - .catch((e) => { if (!silent) setErr(e.message) }) - }, [month]) - - const loadDetail = useCallback((silent) => { - if (!silent) setErr('') - checkinApi.habits(date) - .then(setDetail) - .catch((e) => { if (!silent) setErr(e.message) }) - }, [date]) - - useEffect(() => { loadCal(false) }, [loadCal]) - useEffect(() => { setDetail(null); loadDetail(false) }, [loadDetail]) - useRefreshOnVisible(() => { loadCal(true); loadDetail(true) }) - - // 切换日期时若跨月, 同步月份 - useEffect(() => { - const m = date.slice(0, 7) - setMonth((cur) => (cur === m ? cur : m)) - }, [date]) - - const days = useMemo(() => monthGrid(month), [month]) - const byDate = useMemo(() => { - const map = new Map() - ;(cal?.days || []).forEach((d) => map.set(d.date, d)) - return map - }, [cal]) - - const items = detail?.items || [] - const isFuture = !!detail?.isFuture - - // 按周期分组: 每天 → 每周 → 每月. "每年" 不在合并视图中展示, 但若存在 - // 历史 year 项则归入"每月"之外的单列, 避免数据凭空消失. - const groups = useMemo(() => { - const bucket = new Map() - for (const it of items) { - const key = GROUP_ORDER.includes(it.period) ? it.period : 'other' - if (!bucket.has(key)) bucket.set(key, []) - bucket.get(key).push(it) - } - // 组内排序: 未完成在前, 已完成在后 (沿用 F-CK-16 默认口径) - for (const arr of bucket.values()) { - arr.sort((a, b) => Number(a.completed) - Number(b.completed) || a.id - b.id) - } - const order = [...GROUP_ORDER, 'other'] - return order.filter((k) => bucket.has(k)).map((k) => ({ - key: k, - label: GROUP_LABEL[k] || '其它周期', - items: bucket.get(k), - })) - }, [items]) - - const summary = detail?.summary || { total: 0, done: 0 } - const fireBurst = (id) => setBursts((m) => ({ ...m, [id]: (m[id] || 0) + 1 })) - - // ---------- 打卡 / 撤销 (乐观更新) ---------- - async function doCheck(item) { - // 只按"是否未来日期"拦截. 不能判 item.canCheck —— 该字段仅 /day 接口返回, - // 本视图数据来自 /habits (不含 canCheck), 用它会把所有打卡按钮全部禁用. - if (isFuture) return - const before = detail - setDetail((d) => ({ - ...d, - items: d.items.map((x) => { - if (x.id !== item.id) return x - const done = x.done + 1 - const countOnDate = (x.countOnDate || 0) + 1 - return { - ...x, - done, - remaining: Math.max(0, x.target - done), - percent: Math.min(1, done / x.target), - completed: done >= x.target, - over: Math.max(0, done - x.target), - countOnDate, - dayDone: countOnDate >= x.expectedPerDay, - } - }), - })) - try { - const res = await checkinApi.check(item.id, date) - const fresh = await checkinApi.habits(date) - setDetail(fresh) - if (res.item?.completed) fireBurst(item.id) - loadCal(true) - } catch (e) { - setDetail(before) - Toast.show({ content: e.message || '打卡失败' }) - } - } - - async function doUncheck(item) { - const before = detail - setDetail((d) => ({ - ...d, - items: d.items.map((x) => { - if (x.id !== item.id) return x - const done = Math.max(0, x.done - 1) - const countOnDate = Math.max(0, (x.countOnDate || 0) - 1) - return { - ...x, - done, - remaining: Math.max(0, x.target - done), - percent: Math.min(1, done / x.target), - completed: done >= x.target, - over: Math.max(0, done - x.target), - countOnDate, - dayDone: countOnDate >= x.expectedPerDay, - } - }), - })) - try { - await checkinApi.uncheck(item.id, date) - const fresh = await checkinApi.habits(date) - setDetail(fresh) - loadCal(true) - } catch (e) { - setDetail(before) - Toast.show({ content: e.message || '撤销失败' }) - } - } - - // ---------- 新建 / 编辑 / 归档 ---------- - function openCreate() { - setForm(EMPTY_FORM) - setShowForm(true) - } - - function openEdit(item) { - setSheetItem(null) - setForm({ id: item.id, name: item.name, period: item.period, target: item.target, color: item.color, note: item.note }) - setShowForm(true) - } - - async function submitForm() { - const name = String(form.name || '').trim() - if (!name) return Toast.show({ content: '请填写名称' }) - if (name.length > 20) return Toast.show({ content: '名称不能超过 20 个字' }) - const payload = { name, period: form.period, target: Number(form.target) || 1, color: form.color, note: form.note } - try { - if (form.id) await checkinApi.updateHabit(form.id, payload) - else await checkinApi.createHabit(payload) - setShowForm(false) - setForm(EMPTY_FORM) - loadDetail(true) - loadCal(true) - } catch (e) { - Toast.show({ content: e.message || '保存失败' }) - } - } - - async function doArchive(item) { - setSheetItem(null) - const ok = await Dialog.confirm({ - content: `归档「${item.name}」?归档后不再出现在待办,历史记录保留。`, - confirmText: '归档', - cancelText: '取消', - }) - if (!ok) return - try { - await checkinApi.deleteHabit(item.id) - Toast.show({ content: '已归档' }) - loadDetail(true) - loadCal(true) - } catch (e) { - Toast.show({ content: e.message || '操作失败' }) - } - } - - // ---------- 渲染 ---------- - return ( -
- {/* 月历 */} -
-
- setMonth((m) => addMonths(m, -1))}>‹ - {fmtMonth(month)} - setMonth((m) => addMonths(m, 1))}>› - onDateChange(today)}>回到今天 -
- -
- {weekdayLabels().map((w) => {w})} -
-
- {days.map((d) => { - const info = byDate.get(d) - const inMonth = d.slice(0, 7) === month - const status = info?.status || 'empty' - return ( -
onDateChange(d)} - > - {Number(d.slice(8, 10))} - {inMonth && status !== 'empty' && } -
- ) - })} -
- - {cal && ( -
- 本月完成率 - {Math.round((cal.rate || 0) * 100)}% -
- )} -
- - {err &&
{err} { loadCal(false); loadDetail(false) }}>重试
} - - {/* 当日栏: 右侧放「+ 新建」, 替代原来无实际作用的「点环即打卡」提示 */} -
- {dayLabel(date, detail?.today || today)} - {isFuture - ? 计划(不可打卡) - : 已完成 {summary.done} / {summary.total}} - -
- - {detail === null && !err &&
加载中...
} - - {detail !== null && items.length === 0 && ( -
- - -
- )} - - {/* 分组列表 */} -
- {groups.map((g) => ( -
-
{g.label}{g.items.length} 项
- {g.items.map((item) => ( -
- doCheck(item)} - /> -
-
{item.name}
-
- {item.periodLabel} {item.target} 次 · 当日 {item.countOnDate || 0}/{item.expectedPerDay} - {item.streak > 0 && 连续 {item.streak}} -
-
- {isFuture ? ( -
计划
- ) : ( -
doCheck(item)} - > - {item.completed ? '再打一次' : '打卡'} -
- )} -
setSheetItem(item)}>⋯
- -
- ))} -
- ))} -
- - {/* 「⋯」操作面板: 撤销 / 编辑 / 归档 */} - setSheetItem(null)} - bodyStyle={{ borderTopLeftRadius: 12, borderTopRightRadius: 12, paddingBottom: 16 }} - > - {sheetItem && ( -
-
{sheetItem.name}
-
0 ? '' : ' disabled')} - onClick={() => { if (sheetItem.done > 0) { doUncheck(sheetItem); setSheetItem(null) } }} - > - 撤销上次打卡 - 今日 {sheetItem.countOnDate || 0} 次 -
-
openEdit(sheetItem)}> - 编辑打卡项 - 名称 / 周期 / 目标 -
-
doArchive(sheetItem)}> - 归档 - 历史记录保留 -
-
- )} -
- - {/* 新建 / 编辑表单 */} - setShowForm(false)} - bodyStyle={{ borderTopLeftRadius: 12, borderTopRightRadius: 12, paddingBottom: 16 }} - > -
-
{form.id ? '编辑打卡项' : '新建打卡项'}
-
- - setForm((f) => ({ ...f, name: v }))} - /> -
-
- - setForm((f) => ({ ...f, period: v[0] }))} - /> -
-
- - setForm((f) => ({ ...f, target: v }))} /> -
-
- -
- {COLORS.map((c) => ( - setForm((f) => ({ ...f, color: c }))} - /> - ))} -
-
-
- - setForm((f) => ({ ...f, note: v }))} - /> -
-
- - -
-
-
-
- ) -} +// 打卡 · 日历与打卡合并视图 (V0.2.1 界面调整, 设计文档 §11.5) +// - 上: 月历 (月份切换 + 回到今天), 去掉「昨天/今天/明天」分段 +// - 下: 当日打卡项, 按 每天 / 每周 / 每月 分组 (去掉「每年」分组) +// - 卡片紧凑化: 内边距 8px, 环形 42px/环宽 5, 间距 7px +// - 撤销 / 编辑 / 归档 收进「⋯」底部面板 +// - 未来日期只读, 按钮灰显「计划」 +import { useCallback, useEffect, useMemo, useState } from 'react' +import { + Button, Dialog, DotLoading, Empty, Input, Popup, Selector, Stepper, Toast, +} from 'antd-mobile' +import RingProgress, { Celebrate } from '../../components/RingProgress.jsx' +import useHabitDrag from '../../components/checkin/useHabitDrag.js' +import { checkinApi } from '../../api/checkin.js' +import useRefreshOnVisible from '../../hooks/useRefreshOnVisible.js' +import { + toDateStr, addDays, addMonths, monthGrid, weekdayLabels, dayLabel, fmtMonth, +} from './date.js' + +// 分组顺序: 每天 → 每周 → 每月 (去掉每年) +const GROUP_ORDER = ['day', 'week', 'month'] +const GROUP_LABEL = { day: '每天', week: '每周', month: '每月' } + +const PERIOD_OPTIONS = [ + { label: '每天', value: 'day' }, + { label: '每周', value: 'week' }, + { label: '每月', value: 'month' }, + { label: '每年', value: 'year' }, +] + +const COLORS = ['#1677ff', '#00b578', '#ff8f1f', '#ff3141', '#a855f7', '#14b8a6', '#f43f9e'] +const EMPTY_FORM = { id: null, name: '', period: 'day', target: 1, color: COLORS[0], note: '' } + +export default function MergedView({ date, onDateChange }) { + const today = toDateStr() + const [month, setMonth] = useState(() => date.slice(0, 7)) + const [cal, setCal] = useState(null) // 月历数据 { days, rate } + const [detail, setDetail] = useState(null) // 选中日详情 { today, date, items, summary, isFuture } + const [err, setErr] = useState('') + const [bursts, setBursts] = useState({}) + const [sheetItem, setSheetItem] = useState(null) // 「⋯」面板对应的打卡项 + const [form, setForm] = useState(EMPTY_FORM) + const [showForm, setShowForm] = useState(false) + + // ---------- 数据加载 ---------- + const loadCal = useCallback((silent) => { + if (!silent) setErr('') + checkinApi.calendar(month) + .then(setCal) + .catch((e) => { if (!silent) setErr(e.message) }) + }, [month]) + + const loadDetail = useCallback((silent) => { + if (!silent) setErr('') + checkinApi.habits(date) + .then(setDetail) + .catch((e) => { if (!silent) setErr(e.message) }) + }, [date]) + + useEffect(() => { loadCal(false) }, [loadCal]) + useEffect(() => { setDetail(null); loadDetail(false) }, [loadDetail]) + useRefreshOnVisible(() => { loadCal(true); loadDetail(true) }) + + // 切换日期时若跨月, 同步月份 + useEffect(() => { + const m = date.slice(0, 7) + setMonth((cur) => (cur === m ? cur : m)) + }, [date]) + + const days = useMemo(() => monthGrid(month), [month]) + const byDate = useMemo(() => { + const map = new Map() + ;(cal?.days || []).forEach((d) => map.set(d.date, d)) + return map + }, [cal]) + + const items = detail?.items || [] + const isFuture = !!detail?.isFuture + + // 按周期分组: 每天 → 每周 → 每月. "每年" 不在合并视图中展示, 但若存在 + // 历史 year 项则归入"每月"之外的单列, 避免数据凭空消失. + // 组内顺序: 手动排序 (sortOrder) 优先 —— 拖动排序需要稳定口径, + // 服务端 /habits 已按 sortOrder 返回, 此处仅兜底。 + const groups = useMemo(() => { + const bucket = new Map() + for (const it of items) { + const key = GROUP_ORDER.includes(it.period) ? it.period : 'other' + if (!bucket.has(key)) bucket.set(key, []) + bucket.get(key).push(it) + } + for (const arr of bucket.values()) { + arr.sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0) || a.id - b.id) + } + const order = [...GROUP_ORDER, 'other'] + return order.filter((k) => bucket.has(k)).map((k) => ({ + key: k, + label: GROUP_LABEL[k] || '其它周期', + items: bucket.get(k), + })) + }, [items]) + + const 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 fireBurst = (id) => setBursts((m) => ({ ...m, [id]: (m[id] || 0) + 1 })) + + // ---------- 打卡 / 撤销 (乐观更新) ---------- + async function doCheck(item) { + // 只按"是否未来日期"拦截. 不能判 item.canCheck —— 该字段仅 /day 接口返回, + // 本视图数据来自 /habits (不含 canCheck), 用它会把所有打卡按钮全部禁用. + if (isFuture) return + const before = detail + setDetail((d) => ({ + ...d, + items: d.items.map((x) => { + if (x.id !== item.id) return x + const done = x.done + 1 + const countOnDate = (x.countOnDate || 0) + 1 + return { + ...x, + done, + remaining: Math.max(0, x.target - done), + percent: Math.min(1, done / x.target), + completed: done >= x.target, + over: Math.max(0, done - x.target), + countOnDate, + dayDone: countOnDate >= x.expectedPerDay, + } + }), + })) + try { + const res = await checkinApi.check(item.id, date) + const fresh = await checkinApi.habits(date) + setDetail(fresh) + if (res.item?.completed) fireBurst(item.id) + loadCal(true) + } catch (e) { + setDetail(before) + Toast.show({ content: e.message || '打卡失败' }) + } + } + + async function doUncheck(item) { + const before = detail + setDetail((d) => ({ + ...d, + items: d.items.map((x) => { + if (x.id !== item.id) return x + const done = Math.max(0, x.done - 1) + const countOnDate = Math.max(0, (x.countOnDate || 0) - 1) + return { + ...x, + done, + remaining: Math.max(0, x.target - done), + percent: Math.min(1, done / x.target), + completed: done >= x.target, + over: Math.max(0, done - x.target), + countOnDate, + dayDone: countOnDate >= x.expectedPerDay, + } + }), + })) + try { + await checkinApi.uncheck(item.id, date) + const fresh = await checkinApi.habits(date) + setDetail(fresh) + loadCal(true) + } catch (e) { + setDetail(before) + Toast.show({ content: e.message || '撤销失败' }) + } + } + + // ---------- 新建 / 编辑 / 归档 ---------- + function openCreate() { + setForm(EMPTY_FORM) + setShowForm(true) + } + + function openEdit(item) { + setSheetItem(null) + setForm({ id: item.id, name: item.name, period: item.period, target: item.target, color: item.color, note: item.note }) + setShowForm(true) + } + + async function submitForm() { + const name = String(form.name || '').trim() + if (!name) return Toast.show({ content: '请填写名称' }) + if (name.length > 20) return Toast.show({ content: '名称不能超过 20 个字' }) + const payload = { name, period: form.period, target: Number(form.target) || 1, color: form.color, note: form.note } + try { + if (form.id) await checkinApi.updateHabit(form.id, payload) + else await checkinApi.createHabit(payload) + setShowForm(false) + setForm(EMPTY_FORM) + loadDetail(true) + loadCal(true) + } catch (e) { + Toast.show({ content: e.message || '保存失败' }) + } + } + + async function doArchive(item) { + setSheetItem(null) + const ok = await Dialog.confirm({ + content: `归档「${item.name}」?归档后不再出现在待办,历史记录保留。`, + confirmText: '归档', + cancelText: '取消', + }) + if (!ok) return + try { + await checkinApi.deleteHabit(item.id) + Toast.show({ content: '已归档' }) + loadDetail(true) + loadCal(true) + } catch (e) { + Toast.show({ content: e.message || '操作失败' }) + } + } + + // ---------- 渲染 ---------- + return ( +
+ {/* 月历 */} +
+
+ setMonth((m) => addMonths(m, -1))}>‹ + {fmtMonth(month)} + setMonth((m) => addMonths(m, 1))}>› + onDateChange(today)}>回到今天 +
+ +
+ {weekdayLabels().map((w) => {w})} +
+
+ {days.map((d) => { + const info = byDate.get(d) + const inMonth = d.slice(0, 7) === month + const status = info?.status || 'empty' + return ( +
onDateChange(d)} + > + {Number(d.slice(8, 10))} + {inMonth && status !== 'empty' && } +
+ ) + })} +
+ + {cal && ( +
+ 本月完成率 + {Math.round((cal.rate || 0) * 100)}% +
+ )} +
+ + {err &&
{err} { loadCal(false); loadDetail(false) }}>重试
} + + {/* 当日栏: 右侧放「+ 新建」, 替代原来无实际作用的「点环即打卡」提示 */} +
+ {dayLabel(date, detail?.today || today)} + {isFuture + ? 计划(不可打卡) + : 已完成 {summary.done} / {summary.total}} + +
+ + {detail === null && !err &&
加载中...
} + + {detail !== null && items.length === 0 && ( +
+ + +
+ )} + + {/* 分组列表 */} +
+ {groups.map((g) => ( +
+
{g.label}{g.items.length} 项
+ {g.items.map((item) => ( +
+ ⠿ + { if (!drag.justEnded()) doCheck(item) }} + /> +
+
{item.name}
+
+ {item.periodLabel} {item.target} 次 · 当日 {item.countOnDate || 0}/{item.expectedPerDay} + {item.streak > 0 && 连续 {item.streak}} +
+
+ {isFuture ? ( +
计划
+ ) : ( +
{ if (!drag.justEnded()) doCheck(item) }} + > + {item.completed ? '再打一次' : '打卡'} +
+ )} +
setSheetItem(item)}>⋯
+ +
+ ))} +
+ ))} +
+ + {/* 「⋯」操作面板: 撤销 / 编辑 / 归档 */} + setSheetItem(null)} + bodyStyle={{ borderTopLeftRadius: 12, borderTopRightRadius: 12, paddingBottom: 16 }} + > + {sheetItem && ( +
+
{sheetItem.name}
+
0 ? '' : ' disabled')} + onClick={() => { if (sheetItem.done > 0) { doUncheck(sheetItem); setSheetItem(null) } }} + > + 撤销上次打卡 + 今日 {sheetItem.countOnDate || 0} 次 +
+
openEdit(sheetItem)}> + 编辑打卡项 + 名称 / 周期 / 目标 +
+
doArchive(sheetItem)}> + 归档 + 历史记录保留 +
+
+ )} +
+ + {/* 新建 / 编辑表单 */} + setShowForm(false)} + bodyStyle={{ borderTopLeftRadius: 12, borderTopRightRadius: 12, paddingBottom: 16 }} + > +
+
{form.id ? '编辑打卡项' : '新建打卡项'}
+
+ + setForm((f) => ({ ...f, name: v }))} + /> +
+
+ + setForm((f) => ({ ...f, period: v[0] }))} + /> +
+
+ + setForm((f) => ({ ...f, target: v }))} /> +
+
+ +
+ {COLORS.map((c) => ( + setForm((f) => ({ ...f, color: c }))} + /> + ))} +
+
+
+ + setForm((f) => ({ ...f, note: v }))} + /> +
+
+ + +
+
+
+
+ ) +} diff --git a/src/pages/task/BoardView.jsx b/src/pages/task/BoardView.jsx index 2506fc1..a434c7b 100644 --- a/src/pages/task/BoardView.jsx +++ b/src/pages/task/BoardView.jsx @@ -1,8 +1,39 @@ +import { useEffect, useRef, useState } from 'react' import TaskCard from '../../components/task/TaskCard.jsx' 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 ( +
+
{children}
+
+
+ ) +} + // 看板视图 (T-BD-01, T-UI-07) // 拖动 = 长按 400ms 激活, 卡片原位抬起, 指针划过列高亮, 松手落列 (重点设计 §7.1) +// 横向滚列; 列内独立纵向滚动, 列头固定, 滚动时右缘显示滚动条 // 不做"拖到卡片上转子任务" —— 统一走详情页「转为子任务」 export default function BoardView({ tasks, columns, onMoveColumn, onOpen, onToggleSub, onToggle, onMore, onQuickAdd, @@ -33,14 +64,14 @@ export default function BoardView({ )}
-
+ {list.map((t) => ( { if (!drag.dragTask) onOpen?.(t) }} + onClick={() => { if (!drag.dragTask && !drag.justEnded()) onOpen?.(t) }} onToggleSub={onToggleSub} onToggle={onToggle} onMore={onMore} @@ -48,7 +79,7 @@ export default function BoardView({ ))} {list.length === 0 &&
暂无任务
}
onQuickAdd?.(col.id)}>+ 添加任务
-
+
) })} diff --git a/src/pages/task/ListView.jsx b/src/pages/task/ListView.jsx index 5851043..326abff 100644 --- a/src/pages/task/ListView.jsx +++ b/src/pages/task/ListView.jsx @@ -1,13 +1,16 @@ import { useState } from 'react' import TaskRow from '../../components/task/TaskRow.jsx' +import useRowDrag from '../../components/task/useRowDrag.js' // 列表视图 (T-VW-01, T-ST-04/05) // 按看板列分组展示; 子任务缩进 + 折叠展开 -// 子任务的添加只在任务详情页完成 (或把别的任务转为子任务), 列表页不放入口 +// 换列: 行/把手拖动, 跟手浮层, 手指划到哪个分组松手就落到哪 (与看板一致) +// (列表只展示有任务的列, 空列需到看板视图拖入) export default function ListView({ - tasks, columns, onToggle, onOpen, onMore, onLongPress, + tasks, columns, onToggle, onOpen, onMore, onMoveColumn, onReorder, }) { const [collapsed, setCollapsed] = useState({}) + const drag = useRowDrag({ columns, onDrop: onMoveColumn, onReorder }) if (!tasks || tasks.length === 0) { return ( @@ -32,7 +35,7 @@ export default function ListView({ return (
{groups.map((g) => ( -
+
{g.name} {g.list.length} @@ -41,19 +44,22 @@ export default function ListView({ {g.list.map((t) => { const kids = t.children || [] const isCollapsed = !!collapsed[t.id] + const dragging = drag.drag?.task?.id === t.id ? drag.drag : null return (
onOpen(t)} + onClick={() => { if (!drag.justEnded()) onOpen(t) }} onMore={onMore} - onLongPress={onLongPress} + bindDragHandle={drag.bindHandle} + dragging={!!dragging} + dragOver={drag.over?.id === t.id ? drag.over : null} /> {kids.length > 0 && ( <> {!isCollapsed && kids.map((c) => ( - onOpen(c)} onMore={onMore} onLongPress={onLongPress} /> + onOpen(c)} onMore={onMore} /> ))}
=0 编辑既有块 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 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() }) + // 键盘弹出插入量: 信息块编辑弹层用 + const kb = useKeyboardInset() if (view === 'columns') { 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: '退出该项目?退出后将不再可见。', confirmText: '退出', }) @@ -164,6 +238,16 @@ export default function ProjectManage({ project, onClose, onChanged, onExited }) 列设置 ›
+
任务类型
+
{ setKinds(project.taskKinds || DEFAULT_KINDS); setNewKind(''); setKindEdit(true) }}> +
+ {(project.taskKinds || DEFAULT_KINDS).map((k) => ( + {k} + ))} +
+ 编辑 › +
+
信息块({notes.length})
{notes.map((n, i) => (
项目信息
+
+ 项目图标 +
+ {project.icon ? '' : (project.name || '?').slice(0, 1)} +
+ {project.icon && 恢复} + 更换 + +
{ setName(project.name); setRenaming(true) }}> 项目名称 {project.name} @@ -225,12 +328,60 @@ export default function ProjectManage({ project, onClose, onChanged, onExited }) onClose={() => setRenaming(false)} /> + {/* 任务类型编辑: chips 删除 + 输入新增 */} + setKindEdit(false)} + bodyStyle={{ + borderTopLeftRadius: 12, + borderTopRightRadius: 12, + padding: `16px 16px calc(env(safe-area-inset-bottom, 0px) + 20px + ${kb}px)`, + }} + > +
任务类型
+
任务的分类标签,显示在任务卡片左下角
+ +
+ {kinds.map((k) => ( + + {k} + setKinds((p) => p.filter((x) => x !== k))} + >× + + ))} +
+ +
+ setNewKind(e.target.value)} + onKeyDown={(e) => { if (e.key === 'Enter') addKind() }} + /> + 添加 +
+ +
保存
+
setKindEdit(false)}>取消
+
+ {/* 信息块编辑 (T-PJ-13): 标题 + 长文本, 独立编辑 */} setNoteEdit(null)} 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)`, + }} >
{noteEdit?.index >= 0 ? '编辑信息块' : '添加信息块'}
用于记录 Readme 等项目相关长文本,每块独立编辑
@@ -249,7 +400,6 @@ export default function ProjectManage({ project, onClose, onChanged, onExited })