Merge remote-tracking branch 'origin/master' into dev-0.2
Docker Build and Push / build-image (push) Successful in 1m6s
Docker Build and Push / build-image (push) Successful in 1m6s
# Conflicts: # CHANGELOG.md
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
# 大目录/与镜像无关的内容不进 build context
|
||||
# 注意: dist/ 和 ota-package/ 不能忽略 —— CI 预构建产物要通过 context 进入镜像
|
||||
.git
|
||||
.gitea
|
||||
.local
|
||||
.pnpm-store
|
||||
node_modules
|
||||
src-tauri
|
||||
docs
|
||||
*.log
|
||||
readme
|
||||
+190
-182
@@ -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}"
|
||||
|
||||
+149
-114
@@ -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 }}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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 实测拖动中容器背景已透明、宽度正常。
|
||||
@@ -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, '')))
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
Exception during registration:
|
||||
System.ComponentModel.Win32Exception (0x80004005): 系统找不到指定的文件。
|
||||
在 System.Diagnostics.Process.StartWithCreateProcess(ProcessStartInfo startInfo)
|
||||
在 System.CommandLine.Invocation.Process.StartProcess(String command, String args, String workingDir, Action`1 stdOut, Action`1 stdErr, ValueTuple`2[] environmentVariables)
|
||||
在 System.CommandLine.Builder.CommandLineBuilderExtensions.<>c.<<RegisterWithDotnetSuggest>b__5_1>d.MoveNext()
|
||||
+25
-8
@@ -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 | 要点 |
|
||||
| --- | --- | --- | --- |
|
||||
| — | — | — | 暂无 |
|
||||
|
||||
+84
-72
@@ -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"]
|
||||
+101
-88
@@ -1,5 +1,28 @@
|
||||
# iBoard
|
||||
用于收集和整理经济数据,但是以策略为主体重新组织。
|
||||
|
||||
跨平台个人看板 (Tauri + React + Express + MariaDB)。用于收集和整理经济数据,但是以策略为主体重新组织。
|
||||
|
||||
- 首页主要是放经济文章
|
||||
- 数据主要是放各种经济数据和图表
|
||||
- 策略是放按照经济理论组织的相关数据和图表的对比
|
||||
|
||||
## 首页
|
||||
|
||||
首页显示新闻模块和报警模块的数据,按照时间最新优先,无限滚动,直到数据库中没有数据为止。
|
||||
|
||||
### 新闻模块
|
||||
|
||||
增加一个新闻模块,数据来源是每日夜间 3 点,通过妙想 MCP 获取国内外最新经济政治新闻,按影响大小排序 10 条。存入数据库。
|
||||
|
||||
### 报警模块
|
||||
|
||||
World Bank 中的数据只用来对比妙想 MCP 拉取的数据正确性。当同一时间的同一项数据不同时,记录一个报警。
|
||||
|
||||
## 监控
|
||||
|
||||
监控分为服务器监控和服务监控两部分。服务器监控对接 komari,地址:<https://komari.honor3.com/>
|
||||
|
||||
---
|
||||
|
||||
## 分支规则
|
||||
|
||||
@@ -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-<major>.<minor>` 分支前缀对应。
|
||||
- 分支口径:**dev-0.2 开发,发布一律走 master**。
|
||||
|
||||
### build 标识
|
||||
|
||||
- 格式统一为 **`YYYYMMDDHHmmss`(北京时间)**:
|
||||
- 前端 `BUILD_ID`:vite 编译时生成(`vite.config.js#makeBuildId`),无需手动维护;
|
||||
- OTA 包 `build_id`:打包时按同一格式生成(`scripts/build-ota.mjs`)。
|
||||
- OTA 包的 `sequence`(Unix 毫秒,单调递增)保留,仅用于判新与热插拔协议,不再用于展示。
|
||||
|
||||
### PATCH 小版本号
|
||||
|
||||
- 仅在**明确提出**时自增(改 `package.json` version 第三位),否则一直沿用。
|
||||
- 每次自增在 [CHANGELOG.md](CHANGELOG.md) 追加一行(patch 号 / 日期 / commit / 要点)。
|
||||
|
||||
### 改版本号时要同步的位置
|
||||
|
||||
| 文件 | 字段 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `package.json` | `version` | **唯一源**,前端 `src/version.js` 与 OTA 打包脚本均读取此值 |
|
||||
| `src-tauri/tauri.conf.json` | `version` | Tauri 应用版本 → **APK 的 versionName**、桌面安装包版本 |
|
||||
| `src-tauri/Cargo.toml` | `[package].version` | Rust 包版本,需与 tauri.conf.json 一致 |
|
||||
| `src-tauri/Cargo.lock` | `name = "iboard"` 条目 | 同上,随 Cargo.toml 一起改 |
|
||||
|
||||
`scripts/ci/check-version.mjs` 会在 CI 第一段就校验这四处**完全相等**且不含 `-ota`,
|
||||
并校验 `dev-<major>.<minor>` 分支与版本前缀对应;任何一项不满足直接构建失败(`main`/`master` 只跳过分支校验,一致性照样查)。
|
||||
| 组 | 文件 | 字段 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| 前端 | `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` = `<package.json 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 <sha> --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` 验证 → 提交迁移目录。
|
||||
@@ -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` |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
# 基于 PowerSync 的离线编辑可行性研究报告
|
||||
|
||||
| 项 | 内容 |
|
||||
| --- | --- |
|
||||
| 版本 | V0.2 |
|
||||
| 日期 | 2026-10-25 |
|
||||
| 评估对象 | PowerSync(powersync.com,开源 self-hosted + 云服务) |
|
||||
| 评估目标 | 项目离线操作(本地编辑)+ 联网后自动同步 |
|
||||
| 结论(TL;DR) | **技术上可行,但落地成本高、风险中高。不建议全量接入;若确需离线,建议仅对「打卡 / 清单 / 任务」等用户编辑域做局部接入,且需要先解决 MariaDB 兼容与主键策略两个前置问题。** |
|
||||
|
||||
---
|
||||
|
||||
## 1. 项目现状约束(评估输入)
|
||||
|
||||
结合代码库(`package.json`、`prisma/schema.prisma`、`server/index.js`)梳理:
|
||||
|
||||
1. **前端**:React 18 SPA(antd-mobile),通过 axios 调 `/api/*` REST 接口;`src/api`、`src/hooks`、`src/pages` 均以"请求 → state → 渲染"模式组织,无任何本地持久化数据层。
|
||||
2. **桌面/移动端**:Tauri 2(Windows WebView2 桌面 + Android System WebView),意味着前端运行在 WebView 内,浏览器类 SDK 是候选。
|
||||
3. **后端**:Express + Prisma,数据库为 **MariaDB / MySQL**(`provider = "mysql"`)。
|
||||
4. **认证**:自签 JWT(jsonwebtoken),登录接口在 `/api/auth`。
|
||||
5. **写路径**:全部业务写操作走 REST(打卡、清单、任务模块);同时存在大量**服务端定时写入**(cron:新闻、指标、监控、米家采样等,见 `server/cron.js`)。
|
||||
6. **数据模型**:约 25 张表,主键全部为 `Int @id @default(autoincrement())` 自增整数,多表带复合唯一约束(如 `CheckIn @@unique([habitId, date, seq])`)。
|
||||
7. **数据体量特征**:两类数据并存——
|
||||
- **用户编辑域**(Habit / CheckIn / CheckList* / 任务模块 10 张表):冲突敏感、离线价值高;
|
||||
- **服务端只读时序域**(News / Indicator / ServerNodeHistory / MihomeEnv* 等):append-only、量大、离线价值中等(缓存即可满足)。
|
||||
|
||||
---
|
||||
|
||||
## 2. PowerSync 是什么、怎么工作
|
||||
|
||||
- 架构:客户端本地 **SQLite**(Web 端为 WASM SQLite,存 OPFS/IndexedDB)↔ **PowerSync Service**(self-hosted 开源组件或云服务)↔ 源数据库。服务端通过 **逻辑复制/变更流** 捕获源库写入,按 **Sync Rules** 定义的下推子集同步到每个客户端的本地 SQLite。
|
||||
- 写路径(官方推荐模式):客户端离线写本地 SQLite → 进入 **upload queue** → 联网后由前端把变更批量 POST 到**现有后端 API** → 后端用原有方式(本项目即 Prisma)写库 → 变更流捕获 → 广播回各端。
|
||||
- 认证:客户端持后端签发的 JWT 连接同步服务;PowerSync 只做校验,不签发(可复用现有 jsonwebtoken 体系)。
|
||||
- 服务端直写的表(cron 产生)也能被变更流捕获并下推,不需要改造写入方。
|
||||
|
||||
参考:[PowerSync Service(GitHub)](https://github.com/powersync-ja/powersync-service)、[源数据库支持](https://docs.powersync.com/configuration/source-db/setup)。
|
||||
|
||||
---
|
||||
|
||||
## 3. 逐项可行性分析
|
||||
|
||||
### 3.1 数据库兼容性 —— ⚠️ 主要风险点
|
||||
|
||||
- PowerSync 对源库支持:**PostgreSQL(GA)**、MongoDB、SQL Server;**MySQL 仍处于 Beta**(基于 binlog 复制,见 [MySQL Beta 公告](https://releases.powersync.com/announcements/mysql-backend-database-support-has-reached-beta)、[Alpha 公告](https://releases.powersync.com/announcements/announcing-mysql-as-a-source-database-alpha))。
|
||||
- 本项目用的是 **MariaDB**。MariaDB 的 binlog 格式与 MySQL 8 有差异(GTID、行事件细节),PowerSync MySQL 插件(@powersync/mysql-zongji 系)对 MariaDB **没有官方兼容承诺,需要实测验证**,很可能踩坑。
|
||||
- **结论**:要么实测 MariaDB + MySQL-beta 通路(风险高),要么把数据库迁到 PostgreSQL(PowerSync 最成熟路径,Prisma 迁移成本可控但涉及全部表和线上数据)。这是"能不能用"的第一道门槛。
|
||||
|
||||
### 3.2 数据模型适配 —— 改造成本大
|
||||
|
||||
- PowerSync Sync Rules 要求同步表有稳定的行标识,**强烈建议 UUID 文本主键**;而本项目全部是自增整数主键,且客户端离线创建记录时无法预知自增 id。
|
||||
- 离线创建的通用解法:主键改为客户端生成的 UUID(`char(36)`),或新增 `uuid` 列 + 应用层幂等 upsert。对用户编辑域的 10 余张表意味着**一次不小的 schema 迁移 + 全部相关路由改造**(迁移原则"只增不改"仍可遵守,但外键列类型变更工作量大)。
|
||||
- `DateTime`、`Json` 类型可映射(客户端以 ISO 文本/JSON 文本存储),但 `BigInt`(监控表)在 JS 端需按文本处理,注意精度。
|
||||
- 复合唯一约束(`CheckIn(habitId,date,seq)`、`BoardColumn(projectId,name)` 等)在离线多端并发下会冲突,需在 upload 处理端路由里做服务端仲裁(如 seq 重排、重名加后缀),现有路由大多不具备幂等/仲裁语义。
|
||||
|
||||
### 3.3 写路径(离线编辑 + 上行同步)—— 模式契合,但需逐路由改造
|
||||
|
||||
- PowerSync 的 upload-queue → 后端 API 模式与现有 Express 架构**天然契合**:不需要后端暴露直接写库的通口,各业务路由补成"接收客户端 UUID + 幂等 upsert"即可。
|
||||
- 打卡/清单/任务的路由数量约 3 个 router 文件(checkin / checklists / projects / tasks),逐个改造为幂等是可控的。
|
||||
- 服务端 cron 写入的表(新闻/指标/监控/米家)无需改造写入方,只需在 Sync Rules 里定义是否下推。
|
||||
|
||||
### 3.4 读路径与前端数据层 —— 全面前重写
|
||||
|
||||
这是**工作量最大**的部分:
|
||||
|
||||
- 现有 `src/api` + axios + useEffect 模式必须替换为 PowerSync 本地 SQLite 查询 + `@powersync/react` 的响应式 hooks(useQuery 等),否则离线读不到数据。
|
||||
- 涉及 `src/pages` 下全部用户编辑域页面(checkin、task),以及若要离线浏览还需覆盖 news/macro/monitor 的只读缓存页。
|
||||
- 工作量量级:相当于把前端数据层重构一遍;可与"离线只读缓存(HTTP 缓存)"分层实施以降低风险。
|
||||
|
||||
### 3.5 客户端运行环境 —— 大体可行,Android 需验证
|
||||
|
||||
- **浏览器 / Tauri 桌面(WebView2)**:PowerSync Web SDK(WASM SQLite + OPFS)在 Chromium 系 WebView 已被官方支持,可行。
|
||||
- **Tauri Android(System WebView)**:WASM SQLite 性能与 OPFS 在部分国产/旧版 WebView 上存在兼容性风险,需真机验证;备选方案是封装原生 SQLite(官方 Capacitor 插件思路可借鉴,但 Tauri 无官方插件,需自研 Rust 命令桥接,成本额外增加)。
|
||||
- 时序大表(ServerNodeHistory 每分钟一条、MihomeEnv*)不建议纳入同步——Sync Rules 需做时间窗过滤(如仅近 7 天),否则客户端存储和同步流量都会失控。
|
||||
|
||||
### 3.6 认证与部署 —— 可行
|
||||
|
||||
- JWT 可复用:新增一个 `/api/powersync/token` 端点为已登录用户签发 PowerSync 接受的 token,改造小。
|
||||
- 部署:self-hosted 需在现有 Express 之外多跑一个 powersync-service 进程(含 Mongo 存储元数据或云实例),并暴露 WebSocket 端口;或使用 PowerSync Cloud。运维面增加一个组件。
|
||||
|
||||
### 3.7 冲突解决
|
||||
|
||||
- PowerSync 本身不做自动合并,只提供上传队列与冲突回调;对"最后写入胜出"友好的字段(清单勾选、任务标题/状态)默认策略够用;对 `CheckIn.seq`、`BoardColumn.sortOrder` 这类"位置/序号型"数据需要自定义仲裁逻辑(上传端或同步流中处理),设计上可解,但要专门做。
|
||||
|
||||
---
|
||||
|
||||
## 4. 风险与成本汇总
|
||||
|
||||
| # | 风险/成本项 | 等级 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| 1 | MariaDB 兼容性未验证,MySQL 支持仅 Beta | 高 | 很可能需要迁移 PostgreSQL |
|
||||
| 2 | 前端数据层全面重构(axios → 本地 SQLite 查询) | 高 | 涉及所有相关页面 |
|
||||
| 3 | 用户编辑域主键整数自增 → UUID 迁移 | 中高 | schema + 路由幂等改造 |
|
||||
| 4 | 复合唯一约束的离线冲突仲裁 | 中 | CheckIn.seq、列名、排序等 |
|
||||
| 5 | Android WebView WASM SQLite/OPFS 兼容 | 中 | 需真机验证 |
|
||||
| 6 | 新增 self-hosted 组件的运维 | 中 | 多一个常驻服务 |
|
||||
| 7 | 时序大表同步范围控制 | 低 | Sync Rules 时间窗过滤即可 |
|
||||
|
||||
对照收益:真正需要离线**编辑**的只有打卡 / 清单 / 任务三个域(数据量小、单用户为主、冲突场景有限);其余域离线**只读缓存**即可满足。
|
||||
|
||||
---
|
||||
|
||||
## 5. 结论与建议
|
||||
|
||||
### 5.1 结论
|
||||
|
||||
**可行,但不建议以"全项目接入 PowerSync"的方式落地。** PowerSync 的同步模型与项目的 Express API 写路径契合、认证可复用、桌面端可行;但 MariaDB/MySQL-beta 兼容、整数自增主键、前端数据层全面重构三座大山使总成本明显高于当前阶段的收益。
|
||||
|
||||
### 5.2 建议(按优先序)
|
||||
|
||||
1. **首选(低成本方案)**:不引入 PowerSync,为用户编辑域实现轻量离线队列——前端把写请求落到 localStorage/IndexedDB 待发队列(含客户端生成的 UUID),联网后重放到现有 REST API(路由补幂等 upsert);只读页用 HTTP 缓存 + SW。可覆盖约 80% 的离线诉求,改动局限在 `src/api` 层与少量路由。
|
||||
2. **若决定采用 PowerSync**:
|
||||
- 前置 1:源库迁移 PostgreSQL(避开 MySQL Beta + MariaDB 兼容雷区);
|
||||
- 前置 2:仅将用户编辑域(Habit/CheckIn/CheckList*/任务模块)纳入 Sync Rules,时序与监控表排除或做时间窗过滤;
|
||||
- 前置 3:这些表的主键/唯一标识改造为客户端可生成的 UUID;
|
||||
- 分阶段:先桌面端(WebView2)验证全链路,再攻 Android WebView 兼容性;
|
||||
- 冲突策略:字段级 LWW 为主,seq/sortOrder 类服务端仲裁。
|
||||
3. **不建议**:为离线只读域(新闻/指标/监控)上 PowerSync——用简单缓存即可,同步引擎属于过度设计。
|
||||
|
||||
---
|
||||
|
||||
## 6. 参考资料
|
||||
|
||||
- [PowerSync Service(GitHub,self-hosted 开源)](https://github.com/powersync-ja/powersync-service)
|
||||
- [PowerSync 源数据库配置文档](https://docs.powersync.com/configuration/source-db/setup)
|
||||
- [MySQL 后端支持 Beta 公告](https://releases.powersync.com/announcements/mysql-backend-database-support-has-reached-beta)
|
||||
- [MySQL 后端支持 Alpha 公告](https://releases.powersync.com/announcements/announcing-mysql-as-a-source-database-alpha)
|
||||
- 本项目:`prisma/schema.prisma`、`server/index.js`、`docs/V0.2/数据库设计文档.md`、`docs/V0.2/任务模块/任务模块数据库设计文档.md`
|
||||
+1
-1
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover, interactive-widget=resizes-content" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#1f2937" />
|
||||
<title>iBoard - 经济数据与策略</title>
|
||||
</head>
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
-- 任务类型标签: 项目级自定义列表 + 任务上的类型
|
||||
ALTER TABLE `Project` ADD COLUMN `taskKinds` JSON NULL;
|
||||
ALTER TABLE `Task` ADD COLUMN `kind` VARCHAR(20) NULL;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- 任务时间标记: 完整日期存储, 前端展示 MM-dd
|
||||
ALTER TABLE `Task` ADD COLUMN `markDate` DATETIME(3) NULL;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- 项目图标: data URL (客户端裁剪 96x96 JPEG, 约 10-20KB)
|
||||
ALTER TABLE `Project` ADD COLUMN `icon` TEXT NULL;
|
||||
@@ -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?
|
||||
|
||||
|
||||
+157
-147
@@ -1,147 +1,157 @@
|
||||
#!/usr/bin/env node
|
||||
// OTA 升级包生成脚本 (兼容 tauri-plugin-hotswap)
|
||||
// 用法:
|
||||
// node scripts/build-ota.mjs [--out <dir>] [--sequence <n>] [--min-binary <x>] [--notes <s>] [--no-sign] [--key <path>]
|
||||
// 流程:
|
||||
// 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 <path>')
|
||||
console.error(' 生成方法: minisign -G -W -p ota-package/hotswap.pub -s ota-package/hotswap.key')
|
||||
process.exit(1)
|
||||
}
|
||||
// minisign -Sm <file> -s <key> 生成 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 <dir>] [--sequence <n>] [--min-binary <x>] [--notes <s>] [--no-sign] [--key <path>]
|
||||
// 流程:
|
||||
// 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 <path>')
|
||||
console.error(' 生成方法: minisign -G -W -p ota-package/hotswap.pub -s ota-package/hotswap.key')
|
||||
process.exit(1)
|
||||
}
|
||||
// minisign -Sm <file> -s <key> 生成 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 指向该目录。')
|
||||
|
||||
@@ -1,17 +1,24 @@
|
||||
#!/usr/bin/env node
|
||||
// CI 专用: 校验版本号四处一致 + 与分支名一致
|
||||
// CI 专用: 校验版本号分组一致 + 前端版本与分支名对应
|
||||
//
|
||||
// 背景:
|
||||
// 版本号只允许一个手工管理点 = package.json 的 version, 其余位置全部派生:
|
||||
// - APK / 桌面安装包 version = <version> (裸版本号, 无后缀)
|
||||
// - OTA 热更包 version = <version>-ota (多一个 -ota 标记)
|
||||
// - 前端运行时 APP_VERSION = <version>
|
||||
// 任何一处漏改都会让线上版本号、APK 版本、OTA 判新三者对不上, 且很难在客户端发现。
|
||||
// 背景 (2026-10-25 调整: 外壳与前端版本号解绑):
|
||||
// 前端 (Web/OTA) 与外壳 (Android/Windows 壳) 独立维护、独立发版,
|
||||
// 两组版本号不再要求相等, 只要求**组内**一致:
|
||||
//
|
||||
// 组1 前端: package.json .version
|
||||
// ├─ 前端运行时 APP_VERSION = <version> (裸, 无后缀)
|
||||
// └─ OTA 热更包 version = <version>-ota (打包脚本追加)
|
||||
//
|
||||
// 组2 外壳: src-tauri/tauri.conf.json .version (= APK versionName)
|
||||
// ├─ src-tauri/Cargo.toml [package].version
|
||||
// └─ src-tauri/Cargo.lock (iboard 条目)
|
||||
// 外壳随 android-*/桌面发布独立演进, 与 package.json 无关。
|
||||
//
|
||||
// 另: package.json 的 version 不允许带 "-ota" (OTA 后缀由打包脚本追加)。
|
||||
//
|
||||
// 校验分两段:
|
||||
// A. 一致性 (任何分支都查): package.json ↔ tauri.conf.json ↔ Cargo.toml ↔ Cargo.lock
|
||||
// 并要求 package.json 的 version 不含 "-ota" (裸版本, OTA 后缀由打包脚本追加)
|
||||
// B. 分支对应 (仅 dev-<版本> 分支):
|
||||
// A. 组内一致性 (任何分支都查)
|
||||
// B. 分支对应 (仅 dev-<版本> 分支, 针对前端组):
|
||||
// dev-<major>.<minor> → version 必须以 <major>.<minor> 开头
|
||||
// dev-<major> → version 必须以 <major>. 开头
|
||||
// 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})`)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+345
-328
@@ -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
|
||||
|
||||
+105
-102
@@ -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 }
|
||||
// - <filename> (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 }
|
||||
// - <filename> (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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
+46
-3
@@ -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) => ({
|
||||
|
||||
@@ -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 (
|
||||
<Routes>
|
||||
<Route path="/login" element={<RedirectIfAuthed><LoginPage /></RedirectIfAuthed>} />
|
||||
|
||||
+42
-41
@@ -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' }),
|
||||
}
|
||||
|
||||
+278
-259
@@ -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-<ms> / 毫秒数) → 'YYYYMMDDHHmmss'
|
||||
// (北京时间, 与 vite BUILD_ID / OTA manifest.build_id 同格式; 无法解析返回 '')
|
||||
export function fmtBuildId(v) {
|
||||
if (v == null) return ''
|
||||
if (typeof v === 'string' && v.startsWith('seq-')) return fmtBuildId(Number(v.slice(4)))
|
||||
if (typeof v === 'string' && /^\d{14}$/.test(v)) return v
|
||||
const ms = Number(v)
|
||||
if (!Number.isFinite(ms) || ms <= 0) return ''
|
||||
const d = new Date(ms + 8 * 3600 * 1000)
|
||||
const p = (n, w) => String(n).padStart(w || 2, '0')
|
||||
return `${d.getUTCFullYear()}${p(d.getUTCMonth() + 1)}${p(d.getUTCDate())}${p(d.getUTCHours())}${p(d.getUTCMinutes())}${p(d.getUTCSeconds())}`
|
||||
}
|
||||
|
||||
// 启动时: 确认当前资源可用 (Tauri only)
|
||||
// 失败/异常都不抛, 仅 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()
|
||||
}
|
||||
|
||||
@@ -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 } }),
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
// 负责人头像: 姓氏首字 + 按名字稳定的底色; 未指派灰色圆点
|
||||
const COLORS = ['#1677ff', '#00b578', '#ff8f1f', '#a855f7', '#14b8a6', '#f43f9e', '#5a6a80']
|
||||
|
||||
export default function Avatar({ name, size = 18 }) {
|
||||
const seed = [...(name || '')].reduce((a, ch) => a + ch.charCodeAt(0), 0)
|
||||
const color = COLORS[seed % COLORS.length]
|
||||
return (
|
||||
<span
|
||||
className="avatar"
|
||||
title={name || '未指派'}
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
fontSize: Math.round(size * 0.52),
|
||||
background: name ? color : '#eef1f5',
|
||||
color: name ? '#fff' : '#b6bcc4',
|
||||
}}
|
||||
>
|
||||
{name ? name.slice(0, 1) : '·'}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -38,7 +38,10 @@ export default function CopyTaskPopup({ task, visible, onClose, onDone }) {
|
||||
<Popup
|
||||
visible={visible}
|
||||
onMaskClick={onClose}
|
||||
className="kb-safe"
|
||||
bodyStyle={{
|
||||
borderTopLeftRadius: 12, borderTopRightRadius: 12,
|
||||
padding: '16px 16px calc(env(safe-area-inset-bottom, 0px) + 20px)',
|
||||
}}
|
||||
>
|
||||
<div className="sheet-title">跨项目拷贝</div>
|
||||
<div className="sheet-sub">「{task.title}」→ 选择目标项目;源任务保持不变</div>
|
||||
|
||||
@@ -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) }}
|
||||
>
|
||||
<div className="rp-ico" style={{ background: colorOf(p.id) }}>
|
||||
{(p.name || '?').slice(0, 1)}
|
||||
<div
|
||||
className="rp-ico"
|
||||
style={p.icon
|
||||
? { background: `url(${p.icon}) center/cover no-repeat` }
|
||||
: { background: colorOfProject(p) }}
|
||||
>
|
||||
{p.icon ? '' : (p.name || '?').slice(0, 1)}
|
||||
</div>
|
||||
<div className="rp-name">{p.name}</div>
|
||||
<div className="rp-meta">{p.taskCount ?? 0} 个任务</div>
|
||||
|
||||
@@ -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) : {})}
|
||||
>
|
||||
<div className="brow1">
|
||||
<span className="btitle">{task.title}</span>
|
||||
{/* 左侧竖排: 状态点 + ⋯ */}
|
||||
<div className="bcard-left">
|
||||
<span
|
||||
className="bstat"
|
||||
onClick={(e) => { e.stopPropagation(); onToggle?.(task) }}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
title="点按切换完成"
|
||||
>
|
||||
<StatusDot status={task.status} />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="bmeta">
|
||||
<span className={`bassignee${task.assignee ? '' : ' none'}`}>
|
||||
{task.assignee?.name || '未指派'}
|
||||
</span>
|
||||
{task.members?.length > 0 && (
|
||||
<span className="bmembers">+ {task.members.map((m) => m.name).join('、')}</span>
|
||||
)}
|
||||
{onMore && (
|
||||
<span
|
||||
className="bmore-btn"
|
||||
onClick={(e) => { e.stopPropagation(); onMore(task) }}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
>⋯</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{shown.map((c) => (
|
||||
<div
|
||||
key={c.id}
|
||||
className={`bsub${c.status === 'done' ? ' done' : ''}`}
|
||||
onClick={(e) => { e.stopPropagation(); onToggleSub?.(c) }}
|
||||
>
|
||||
<span className="sbox" />
|
||||
<span className="stitle">{c.title}</span>
|
||||
<StatusDot status={c.status} />
|
||||
<div className="bcard-body">
|
||||
<div className="brow1">
|
||||
<span className="btitle">{task.title}</span>
|
||||
</div>
|
||||
))}
|
||||
{rest > 0 && <div className="bmore">+{rest} 个子任务</div>}
|
||||
|
||||
<div className="bmeta">
|
||||
{task.markDate && (
|
||||
<span className="date-tag">{task.markDate.slice(5, 10).replace('-', '/')}</span>
|
||||
)}
|
||||
{task.kind && (
|
||||
<span className="kind-tag" style={{ background: kindColor(task.kind) }}>{task.kind}</span>
|
||||
)}
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 6, marginLeft: 'auto', minWidth: 0, justifyContent: 'flex-end' }}>
|
||||
<Avatar name={task.assignee?.name} size={18} />
|
||||
{task.members?.length > 0 && (
|
||||
<span className="bmembers">+ {task.members.map((m) => m.name).join('、')}</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{shown.map((c) => (
|
||||
<div
|
||||
key={c.id}
|
||||
className={`bsub${c.status === 'done' ? ' done' : ''}`}
|
||||
onClick={(e) => { e.stopPropagation(); onToggleSub?.(c) }}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<span className="sbox" />
|
||||
<span className="stitle">{c.title}</span>
|
||||
<StatusDot status={c.status} />
|
||||
</div>
|
||||
))}
|
||||
{rest > 0 && <div className="bmore">+{rest} 个子任务</div>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
<div
|
||||
className={`${indent ? 'sub-row' : 'task-row'}${done ? ' done' : ''}`}
|
||||
className={
|
||||
`${indent ? 'sub-row' : 'task-row'}${done ? ' done' : ''}`
|
||||
+ (dragging ? ' dragging' : '')
|
||||
+ (dragOver ? ` dragover ${dragOver.after ? 'after' : 'before'}` : '')
|
||||
}
|
||||
data-task-id={indent ? undefined : task.id}
|
||||
onClick={() => { if (!moved.current) onClick?.(task) }}
|
||||
onTouchStart={startPress}
|
||||
onTouchMove={markMoved}
|
||||
onTouchEnd={cancelPress}
|
||||
onMouseDown={startPress}
|
||||
onMouseUp={cancelPress}
|
||||
onClick={onClick}
|
||||
{...(!indent && bindDrag ? bindDrag(task) : {})}
|
||||
>
|
||||
{!indent && bindDragHandle && (
|
||||
<span className="grip" title="拖动排序 / 换列" {...bindDragHandle(task)}>⠿</span>
|
||||
)}
|
||||
<div
|
||||
className="box"
|
||||
onClick={(e) => { e.stopPropagation(); onToggle?.(task) }}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<svg viewBox="0 0 14 14" width={indent ? 10 : 11} height={indent ? 10 : 11}>
|
||||
<path d="M2.5 7.2l3 3 6-6.4" stroke="#fff" strokeWidth="2" fill="none" strokeLinecap="round" />
|
||||
@@ -51,28 +39,37 @@ export default function TaskRow({
|
||||
</div>
|
||||
|
||||
{indent ? (
|
||||
<>
|
||||
<div className="sub-main">
|
||||
<span className="sub-title">{task.title}</span>
|
||||
<StatusDot status={task.status} pill />
|
||||
</>
|
||||
<div className="task-meta sub">
|
||||
<StatusDot status={task.status} />
|
||||
<span>{statusLabel(task.status)}</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="task-main">
|
||||
<div className="task-title">{task.title}</div>
|
||||
{/* 无类型/成员也保留该行, 与有标签的行等高 */}
|
||||
<div className="task-meta">
|
||||
<StatusDot status={task.status} />
|
||||
<span>{task.status === 'done' ? '完成' : task.status === 'doing' ? '执行中' : '未开始'}</span>
|
||||
{task.column?.name && <span className="col">{task.column.name}</span>}
|
||||
<span className={`assignee${task.assignee ? '' : ' none'}`}>
|
||||
{task.assignee?.name || '未指派'}
|
||||
</span>
|
||||
{task.markDate && (
|
||||
<span className="date-tag">{task.markDate.slice(5, 10).replace('-', '/')}</span>
|
||||
)}
|
||||
{task.kind && (
|
||||
<span className="kind-tag" style={{ background: kindColor(task.kind) }}>{task.kind}</span>
|
||||
)}
|
||||
{task.members?.length > 0 && (
|
||||
<span className="members">+ {task.members.map((m) => m.name).join('、')}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="task-right">
|
||||
<span className="more" onClick={(e) => { e.stopPropagation(); onMore?.(task) }}>⋯</span>
|
||||
<span
|
||||
className="more"
|
||||
onClick={(e) => { e.stopPropagation(); onMore?.(task) }}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
>⋯</span>
|
||||
<Avatar name={task.assignee?.name} size={20} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -34,7 +34,7 @@ export default function UserPicker({
|
||||
}
|
||||
|
||||
return (
|
||||
<Popup visible={visible} onMaskClick={onClose} position="bottom" className="kb-safe">
|
||||
<Popup visible={visible} onMaskClick={onClose} bodyStyle={{ borderTopLeftRadius: 12, borderTopRightRadius: 12, padding: '16px 16px calc(env(safe-area-inset-bottom, 0px) + 20px)' }}>
|
||||
<div className="sheet-title">{title}</div>
|
||||
<div className="sheet-sub">仅可选择当前项目的成员</div>
|
||||
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
import { useEffect } from 'react'
|
||||
|
||||
// 键盘遮挡统一方案 (重点设计 §7.2):
|
||||
// 全局唯一监听器, 把键盘插入量写入 CSS 变量 --kb-inset (0 或 px 字符串),
|
||||
// 任何底部弹层只要挂 .kb-safe 类即可被抬到键盘上方, 组件不再各自接键盘逻辑。
|
||||
// 信号源 (双保险):
|
||||
// 1) visualViewport 的 resize/scroll —— Android WebView 键盘弹出时它先变小
|
||||
// 2) window resize —— 配合 index.html 的 interactive-widget=resizes-content,
|
||||
// Chromium 系 WebView 会整体收缩布局视口
|
||||
// 基线: 记录见过的最大视口高 (键盘收起时), 避免"弹出键盘后基线被拉低"的漂移。
|
||||
export default function useKeyboardInsetWatcher() {
|
||||
useEffect(() => {
|
||||
const root = document.documentElement
|
||||
const vv = window.visualViewport
|
||||
let maxH = window.innerHeight
|
||||
|
||||
const calc = () => {
|
||||
const h = vv ? vv.height : window.innerHeight
|
||||
if (h > maxH) maxH = h
|
||||
const inset = Math.max(0, Math.round(maxH - h - (vv ? vv.offsetTop : 0)))
|
||||
root.style.setProperty('--kb-inset', inset > 90 ? inset + 'px' : '0px')
|
||||
}
|
||||
|
||||
calc()
|
||||
window.addEventListener('resize', calc)
|
||||
vv?.addEventListener('resize', calc)
|
||||
vv?.addEventListener('scroll', calc)
|
||||
return () => {
|
||||
window.removeEventListener('resize', calc)
|
||||
vv?.removeEventListener('resize', calc)
|
||||
vv?.removeEventListener('scroll', calc)
|
||||
root.style.setProperty('--kb-inset', '0px')
|
||||
}
|
||||
}, [])
|
||||
}
|
||||
+262
-247
@@ -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 (
|
||||
<div className="me-page">
|
||||
<div className="me-profile">
|
||||
<Avatar
|
||||
className="me-avatar"
|
||||
src={user?.avatar}
|
||||
style={{ '--size': '64px', '--border-radius': '50%' }}
|
||||
>
|
||||
{(user?.name || '?').slice(0, 1).toUpperCase()}
|
||||
</Avatar>
|
||||
<div className="me-name">{user?.name || '用户'}</div>
|
||||
<div className="me-email">{user?.email}</div>
|
||||
</div>
|
||||
|
||||
{/* OTA 升级区 */}
|
||||
<List className="me-list" mode="card" header="前端升级 (OTA)">
|
||||
<List.Item extra={'v' + cur.version + ' · ' + cur.build}>当前版本</List.Item>
|
||||
<List.Item extra={otaVersion?.active ? 'v' + (otaVersion.version || '?') : '内置'}>
|
||||
运行资源
|
||||
</List.Item>
|
||||
{/* 判新口径开关: 默认关(版本号比较), 打开后按 build sequence 比较 */}
|
||||
<List.Item
|
||||
extra={
|
||||
<Switch
|
||||
checked={devMode}
|
||||
onChange={(v) => {
|
||||
setDevMode(v)
|
||||
setOtaDevMode(v)
|
||||
// 切换口径后旧检查结果失效, 重置避免误导
|
||||
setOtaState('idle')
|
||||
setOtaInfo(null)
|
||||
setError('')
|
||||
Toast.show({ content: v ? '开发版本: 按构建号判新' : '正式口径: 按版本号判新' })
|
||||
}}
|
||||
/>
|
||||
}
|
||||
description={
|
||||
devMode
|
||||
? '按 build sequence 判新:每次构建都算新版本,便于开发联调'
|
||||
: '按版本号判新:仅当服务端版本号更高时才提示更新'
|
||||
}
|
||||
>
|
||||
开发版本
|
||||
</List.Item>
|
||||
<List.Item>
|
||||
<div className="me-ota-actions">
|
||||
<Button
|
||||
size="small"
|
||||
color="primary"
|
||||
loading={otaState === 'checking'}
|
||||
disabled={otaState === 'downloading'}
|
||||
onClick={onCheck}
|
||||
>
|
||||
检查更新
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
color="danger"
|
||||
disabled={otaState !== 'available' || otaState === 'downloading'}
|
||||
onClick={onApply}
|
||||
>
|
||||
立即升级
|
||||
</Button>
|
||||
{isTauri && otaVersion?.active && (
|
||||
<Button
|
||||
size="small"
|
||||
fill="outline"
|
||||
onClick={onRollback}
|
||||
disabled={otaState === 'downloading'}
|
||||
>
|
||||
回滚
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</List.Item>
|
||||
{otaState === 'available' && otaInfo && (
|
||||
<List.Item extra={'v' + otaInfo.version}>
|
||||
新版本
|
||||
{otaInfo.bundle_size ? ` (${(otaInfo.bundle_size / 1024).toFixed(0)} KB)` : ''}
|
||||
</List.Item>
|
||||
)}
|
||||
{otaState === 'downloading' && (
|
||||
<List.Item>
|
||||
<div className="me-ota-progress">
|
||||
<div className="me-ota-progress-text">
|
||||
正在下载 {showProgress ? progressPct + '%' : ''}
|
||||
{' '}({(progress.received / 1024).toFixed(0)} KB
|
||||
{progress.total ? ' / ' + (progress.total / 1024).toFixed(0) + ' KB' : ''})
|
||||
</div>
|
||||
<ProgressBar percent={progressPct || undefined} />
|
||||
</div>
|
||||
</List.Item>
|
||||
)}
|
||||
{otaInfo?.notes && (
|
||||
<List.Item description={otaInfo.notes}>发布说明</List.Item>
|
||||
)}
|
||||
{error && (
|
||||
<List.Item>
|
||||
<span className="me-ota-error">{error}</span>
|
||||
</List.Item>
|
||||
)}
|
||||
{!isTauri && (
|
||||
<List.Item description="Web 端仅展示提示, 实际升级需在 Tauri 客户端进行">提示</List.Item>
|
||||
)}
|
||||
</List>
|
||||
|
||||
<List className="me-list" mode="card">
|
||||
<List.Item extra={'#' + (user?.id || '-')}>用户 ID</List.Item>
|
||||
<List.Item extra={user?.createdAt ? new Date(user.createdAt).toLocaleDateString() : '-'}>
|
||||
注册时间
|
||||
</List.Item>
|
||||
<List.Item
|
||||
onClick={onLogout}
|
||||
clickable
|
||||
arrowIcon={false}
|
||||
className="me-logout"
|
||||
>
|
||||
退出登录
|
||||
</List.Item>
|
||||
</List>
|
||||
<div className="me-version">v{APP_VERSION} · build {BUILD_ID}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
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 (
|
||||
<div className="me-page">
|
||||
<div className="me-profile">
|
||||
<Avatar
|
||||
className="me-avatar"
|
||||
src={user?.avatar}
|
||||
style={{ '--size': '64px', '--border-radius': '50%' }}
|
||||
>
|
||||
{(user?.name || '?').slice(0, 1).toUpperCase()}
|
||||
</Avatar>
|
||||
<div className="me-name">{user?.name || '用户'}</div>
|
||||
<div className="me-email">{user?.email}</div>
|
||||
</div>
|
||||
|
||||
{/* OTA 升级区 */}
|
||||
<List className="me-list" mode="card" header="前端升级 (OTA)">
|
||||
<List.Item extra={'v' + cur.version + (cur.build ? ' · build ' + cur.build : '')}>当前版本</List.Item>
|
||||
<List.Item extra={otaVersion?.active ? 'v' + (otaVersion.version || '?') : '内置'}>
|
||||
运行资源
|
||||
</List.Item>
|
||||
{/* 判新口径开关: 默认关(版本号比较), 打开后按 build sequence 比较 */}
|
||||
<List.Item
|
||||
extra={
|
||||
<Switch
|
||||
checked={devMode}
|
||||
onChange={(v) => {
|
||||
setDevMode(v)
|
||||
setOtaDevMode(v)
|
||||
// 切换口径后旧检查结果失效, 重置避免误导
|
||||
setOtaState('idle')
|
||||
setOtaInfo(null)
|
||||
setError('')
|
||||
Toast.show({ content: v ? '开发版本: 按构建号判新' : '正式口径: 按版本号判新' })
|
||||
}}
|
||||
/>
|
||||
}
|
||||
description={
|
||||
devMode
|
||||
? '按 build sequence 判新:每次构建都算新版本,便于开发联调'
|
||||
: '按版本号判新:仅当服务端版本号更高时才提示更新'
|
||||
}
|
||||
>
|
||||
开发版本
|
||||
</List.Item>
|
||||
<List.Item>
|
||||
<div className="me-ota-actions">
|
||||
<Button
|
||||
size="small"
|
||||
color="primary"
|
||||
loading={otaState === 'checking'}
|
||||
disabled={otaState === 'downloading'}
|
||||
onClick={onCheck}
|
||||
>
|
||||
检查更新
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
color="danger"
|
||||
disabled={otaState !== 'available' || otaState === 'downloading'}
|
||||
onClick={onApply}
|
||||
>
|
||||
立即升级
|
||||
</Button>
|
||||
{isTauri && otaVersion?.active && (
|
||||
<Button
|
||||
size="small"
|
||||
fill="outline"
|
||||
onClick={onRollback}
|
||||
disabled={otaState === 'downloading'}
|
||||
>
|
||||
回滚
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</List.Item>
|
||||
{otaState === 'available' && otaInfo && (
|
||||
<List.Item extra={'v' + otaInfo.version + (otaInfo.build ? ' · build ' + otaInfo.build : '')}>
|
||||
新版本
|
||||
{otaInfo.bundle_size ? ` (${(otaInfo.bundle_size / 1024).toFixed(0)} KB)` : ''}
|
||||
</List.Item>
|
||||
)}
|
||||
{otaState === 'downloading' && (
|
||||
<List.Item>
|
||||
<div className="me-ota-progress">
|
||||
<div className="me-ota-progress-text">
|
||||
正在下载 {showProgress ? progressPct + '%' : ''}
|
||||
{' '}({(progress.received / 1024).toFixed(0)} KB
|
||||
{progress.total ? ' / ' + (progress.total / 1024).toFixed(0) + ' KB' : ''})
|
||||
</div>
|
||||
<ProgressBar percent={progressPct || undefined} />
|
||||
</div>
|
||||
</List.Item>
|
||||
)}
|
||||
{otaInfo?.notes && (
|
||||
<List.Item description={otaInfo.notes}>发布说明</List.Item>
|
||||
)}
|
||||
{otaInfo?.commit && (
|
||||
<List.Item extra={String(otaInfo.commit).slice(0, 10)}>提交</List.Item>
|
||||
)}
|
||||
{error && (
|
||||
<List.Item>
|
||||
<span className="me-ota-error">{error}</span>
|
||||
</List.Item>
|
||||
)}
|
||||
{!isTauri && (
|
||||
<List.Item description="Web 端仅展示提示, 实际升级需在 Tauri 客户端进行">提示</List.Item>
|
||||
)}
|
||||
</List>
|
||||
|
||||
<List className="me-list" mode="card">
|
||||
<List.Item extra={'#' + (user?.id || '-')}>用户 ID</List.Item>
|
||||
<List.Item extra={user?.createdAt ? new Date(user.createdAt).toLocaleDateString() : '-'}>
|
||||
注册时间
|
||||
</List.Item>
|
||||
<List.Item
|
||||
onClick={onLogout}
|
||||
clickable
|
||||
arrowIcon={false}
|
||||
className="me-logout"
|
||||
>
|
||||
退出登录
|
||||
</List.Item>
|
||||
</List>
|
||||
<div className="me-version">外壳 v{shellVersion || '—'}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
+338
-282
@@ -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 (
|
||||
<div className="habit-view">
|
||||
<div className="habit-head">
|
||||
<div className="habit-head-left">
|
||||
<div className="habit-date">{dayLabel(date, data?.today || toDateStr())}</div>
|
||||
<div className="habit-summary">
|
||||
已完成 <strong>{summary.done}</strong> / {summary.total}
|
||||
{!isToday && <span className="habit-badge">非今天</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="habit-head-right">
|
||||
<Button size="mini" color="primary" onClick={() => { setForm(EMPTY_FORM); setShowForm(true) }}>
|
||||
+ 新建
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{err && (
|
||||
<div className="error-banner">
|
||||
{err}
|
||||
<span className="retry-link" onClick={() => load(false)}>重试</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data === null && !err && <div className="monitor-loading"><DotLoading /> 加载中...</div>}
|
||||
|
||||
{data !== null && !err && items.length === 0 && (
|
||||
<div className="checkin-empty">
|
||||
<Empty description="还没有打卡项" />
|
||||
<Button color="primary" size="small" onClick={() => { setForm(EMPTY_FORM); setShowForm(true) }}>
|
||||
创建第一个打卡项
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="habit-list">
|
||||
{items.map((item) => (
|
||||
<div key={item.id} className={'habit-card' + (item.completed ? ' done' : '')}>
|
||||
<RingProgress
|
||||
percent={item.percent}
|
||||
done={item.done}
|
||||
target={item.target}
|
||||
over={item.over}
|
||||
completed={item.completed}
|
||||
color={item.color}
|
||||
onClick={() => doCheck(item)}
|
||||
/>
|
||||
<div className="habit-main">
|
||||
<div className="habit-name">{item.name}</div>
|
||||
<div className="habit-meta">
|
||||
{item.periodLabel} {item.target} 次
|
||||
{item.streak > 0 && <span className="habit-streak">连续 {item.streak}</span>}
|
||||
</div>
|
||||
{item.note && <div className="habit-note">{item.note}</div>}
|
||||
<div className="habit-range">
|
||||
{item.periodStart === item.periodEnd
|
||||
? '今日'
|
||||
: `${item.periodStart.slice(5)} ~ ${item.periodEnd.slice(5)}`}
|
||||
{item.over > 0 && <span className="habit-over">超额 {item.over}</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="habit-actions">
|
||||
<Button
|
||||
size="mini"
|
||||
color={item.completed ? 'default' : 'primary'}
|
||||
onClick={() => doCheck(item)}
|
||||
>
|
||||
{item.completed ? '再打一次' : '打卡'}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="habit-foot">
|
||||
{item.done > 0 && (
|
||||
<span className="habit-link" onClick={() => doUncheck(item)}>撤销</span>
|
||||
)}
|
||||
<span
|
||||
className="habit-link"
|
||||
onClick={() => {
|
||||
setForm({ id: item.id, name: item.name, period: item.period, target: item.target, color: item.color, note: item.note })
|
||||
setShowForm(true)
|
||||
}}
|
||||
>编辑</span>
|
||||
<span className="habit-link danger" onClick={() => removeHabit(item)}>归档</span>
|
||||
</div>
|
||||
<Celebrate fire={bursts[item.id] || 0} color={item.color} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 新建 / 编辑 */}
|
||||
<Popup
|
||||
visible={showForm}
|
||||
onMaskClick={() => setShowForm(false)}
|
||||
bodyStyle={{ borderTopLeftRadius: 12, borderTopRightRadius: 12, paddingBottom: 16 }}
|
||||
>
|
||||
<div className="form-popup">
|
||||
<div className="form-title">{form.id ? '编辑打卡项' : '新建打卡项'}</div>
|
||||
<div className="form-field">
|
||||
<label>名称</label>
|
||||
<Input
|
||||
placeholder="例如:喝水 / 健身 / 读书"
|
||||
value={form.name}
|
||||
maxLength={20}
|
||||
onChange={(v) => setForm((f) => ({ ...f, name: v }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label>周期</label>
|
||||
<Selector
|
||||
options={PERIOD_OPTIONS}
|
||||
value={[form.period]}
|
||||
onChange={(v) => setForm((f) => ({ ...f, period: v[0] }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label>目标次数(周期内)</label>
|
||||
<Stepper min={1} max={999} value={form.target} onChange={(v) => setForm((f) => ({ ...f, target: v }))} />
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label>颜色</label>
|
||||
<div className="color-row">
|
||||
{COLORS.map((c) => (
|
||||
<span
|
||||
key={c}
|
||||
className={'color-dot' + (form.color === c ? ' active' : '')}
|
||||
style={{ background: c }}
|
||||
onClick={() => setForm((f) => ({ ...f, color: c }))}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label>备注(可选)</label>
|
||||
<Input
|
||||
placeholder="补充说明"
|
||||
value={form.note}
|
||||
onChange={(v) => setForm((f) => ({ ...f, note: v }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-actions">
|
||||
<Button block color="primary" onClick={submitForm}>保存</Button>
|
||||
<Button block fill="outline" onClick={() => setShowForm(false)} style={{ marginTop: 8 }}>取消</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Popup>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
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 (
|
||||
<div className="habit-view">
|
||||
<div className="habit-head">
|
||||
<div className="habit-head-left">
|
||||
<div className="habit-date">{dayLabel(date, data?.today || toDateStr())}</div>
|
||||
<div className="habit-summary">
|
||||
已完成 <strong>{summary.done}</strong> / {summary.total}
|
||||
{!isToday && <span className="habit-badge">非今天</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="habit-head-right">
|
||||
<Button size="mini" color="primary" onClick={() => { setForm(EMPTY_FORM); setShowForm(true) }}>
|
||||
+ 新建
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{err && (
|
||||
<div className="error-banner">
|
||||
{err}
|
||||
<span className="retry-link" onClick={() => load(false)}>重试</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data === null && !err && <div className="monitor-loading"><DotLoading /> 加载中...</div>}
|
||||
|
||||
{data !== null && !err && items.length === 0 && (
|
||||
<div className="checkin-empty">
|
||||
<Empty description="还没有打卡项" />
|
||||
<Button color="primary" size="small" onClick={() => { setForm(EMPTY_FORM); setShowForm(true) }}>
|
||||
创建第一个打卡项
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="habit-list">
|
||||
{groups.map((g) => (
|
||||
<div key={g.key}>
|
||||
<div className="habit-group-title">
|
||||
{g.label}
|
||||
<span className="cnt">{g.list.length}</span>
|
||||
</div>
|
||||
{g.list.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
data-habit-row
|
||||
data-habit-id={item.id}
|
||||
className={
|
||||
'habit-card'
|
||||
+ (item.completed ? ' done' : '')
|
||||
+ (drag.dragItem?.id === item.id ? ' dragging' : '')
|
||||
+ (drag.over?.id === item.id ? ` dragover ${drag.over.after ? 'after' : 'before'}` : '')
|
||||
}
|
||||
{...drag.bind(item)}
|
||||
>
|
||||
<RingProgress
|
||||
percent={item.percent}
|
||||
done={item.done}
|
||||
target={item.target}
|
||||
over={item.over}
|
||||
completed={item.completed}
|
||||
color={item.color}
|
||||
onClick={() => { if (!drag.justEnded()) doCheck(item) }}
|
||||
/>
|
||||
<div className="habit-main">
|
||||
<div className="habit-name">{item.name}</div>
|
||||
<div className="habit-meta">
|
||||
{item.periodLabel} {item.target} 次
|
||||
{item.streak > 0 && <span className="habit-streak">连续 {item.streak}</span>}
|
||||
</div>
|
||||
{item.note && <div className="habit-note">{item.note}</div>}
|
||||
<div className="habit-range">
|
||||
{item.periodStart === item.periodEnd
|
||||
? '今日'
|
||||
: `${item.periodStart.slice(5)} ~ ${item.periodEnd.slice(5)}`}
|
||||
{item.over > 0 && <span className="habit-over">超额 {item.over}</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="habit-actions">
|
||||
<Button
|
||||
size="mini"
|
||||
color={item.completed ? 'default' : 'primary'}
|
||||
onClick={() => { if (!drag.justEnded()) doCheck(item) }}
|
||||
>
|
||||
{item.completed ? '再打一次' : '打卡'}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="habit-foot">
|
||||
{item.done > 0 && (
|
||||
<span className="habit-link" onClick={() => { if (!drag.justEnded()) doUncheck(item) }}>撤销</span>
|
||||
)}
|
||||
<span
|
||||
className="habit-link"
|
||||
onClick={() => {
|
||||
if (drag.justEnded()) return
|
||||
setForm({ id: item.id, name: item.name, period: item.period, target: item.target, color: item.color, note: item.note })
|
||||
setShowForm(true)
|
||||
}}
|
||||
>编辑</span>
|
||||
<span className="habit-link danger" onClick={() => { if (!drag.justEnded()) removeHabit(item) }}>归档</span>
|
||||
</div>
|
||||
<Celebrate fire={bursts[item.id] || 0} color={item.color} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 新建 / 编辑 */}
|
||||
<Popup
|
||||
visible={showForm}
|
||||
onMaskClick={() => setShowForm(false)}
|
||||
bodyStyle={{ borderTopLeftRadius: 12, borderTopRightRadius: 12, paddingBottom: 16 }}
|
||||
>
|
||||
<div className="form-popup">
|
||||
<div className="form-title">{form.id ? '编辑打卡项' : '新建打卡项'}</div>
|
||||
<div className="form-field">
|
||||
<label>名称</label>
|
||||
<Input
|
||||
placeholder="例如:喝水 / 健身 / 读书"
|
||||
value={form.name}
|
||||
maxLength={20}
|
||||
onChange={(v) => setForm((f) => ({ ...f, name: v }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label>周期</label>
|
||||
<Selector
|
||||
options={PERIOD_OPTIONS}
|
||||
value={[form.period]}
|
||||
onChange={(v) => setForm((f) => ({ ...f, period: v[0] }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label>目标次数(周期内)</label>
|
||||
<Stepper min={1} max={999} value={form.target} onChange={(v) => setForm((f) => ({ ...f, target: v }))} />
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label>颜色</label>
|
||||
<div className="color-row">
|
||||
{COLORS.map((c) => (
|
||||
<span
|
||||
key={c}
|
||||
className={'color-dot' + (form.color === c ? ' active' : '')}
|
||||
style={{ background: c }}
|
||||
onClick={() => setForm((f) => ({ ...f, color: c }))}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label>备注(可选)</label>
|
||||
<Input
|
||||
placeholder="补充说明"
|
||||
value={form.note}
|
||||
onChange={(v) => setForm((f) => ({ ...f, note: v }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-actions">
|
||||
<Button block color="primary" onClick={submitForm}>保存</Button>
|
||||
<Button block fill="outline" onClick={() => setShowForm(false)} style={{ marginTop: 8 }}>取消</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Popup>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
+461
-416
@@ -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 (
|
||||
<div className="mv-view">
|
||||
{/* 月历 */}
|
||||
<div className="mv-cal">
|
||||
<div className="mv-month">
|
||||
<span className="arrow" onClick={() => setMonth((m) => addMonths(m, -1))}>‹</span>
|
||||
<b>{fmtMonth(month)}</b>
|
||||
<span className="arrow" onClick={() => setMonth((m) => addMonths(m, 1))}>›</span>
|
||||
<span className="today" onClick={() => onDateChange(today)}>回到今天</span>
|
||||
</div>
|
||||
|
||||
<div className="mv-grid mv-wd">
|
||||
{weekdayLabels().map((w) => <span key={w}>{w}</span>)}
|
||||
</div>
|
||||
<div className="mv-grid">
|
||||
{days.map((d) => {
|
||||
const info = byDate.get(d)
|
||||
const inMonth = d.slice(0, 7) === month
|
||||
const status = info?.status || 'empty'
|
||||
return (
|
||||
<div
|
||||
key={d}
|
||||
className={
|
||||
'mv-day' + (inMonth ? '' : ' out') + (d === date ? ' sel' : '') +
|
||||
(info?.isToday ? ' today' : '') + (info?.isFuture ? ' future' : '')
|
||||
}
|
||||
onClick={() => onDateChange(d)}
|
||||
>
|
||||
<span>{Number(d.slice(8, 10))}</span>
|
||||
{inMonth && status !== 'empty' && <i className={status} />}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{cal && (
|
||||
<div className="mv-rate">
|
||||
<span>本月完成率</span>
|
||||
<b>{Math.round((cal.rate || 0) * 100)}%</b>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{err && <div className="error-banner">{err}<span className="retry-link" onClick={() => { loadCal(false); loadDetail(false) }}>重试</span></div>}
|
||||
|
||||
{/* 当日栏: 右侧放「+ 新建」, 替代原来无实际作用的「点环即打卡」提示 */}
|
||||
<div className="mv-daybar">
|
||||
{dayLabel(date, detail?.today || today)}
|
||||
{isFuture
|
||||
? <span className="badge">计划(不可打卡)</span>
|
||||
: <span className="badge">已完成 {summary.done} / {summary.total}</span>}
|
||||
<Button
|
||||
className="mv-new-btn"
|
||||
size="mini"
|
||||
color="primary"
|
||||
onClick={openCreate}
|
||||
>
|
||||
+ 新建
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{detail === null && !err && <div className="monitor-loading"><DotLoading /> 加载中...</div>}
|
||||
|
||||
{detail !== null && items.length === 0 && (
|
||||
<div className="checkin-empty">
|
||||
<Empty description="当天没有打卡项" />
|
||||
<Button color="primary" size="small" onClick={openCreate}>创建第一个打卡项</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 分组列表 */}
|
||||
<div className="mv-groups">
|
||||
{groups.map((g) => (
|
||||
<div key={g.key}>
|
||||
<div className="mv-ghead">{g.label}<span className="cnt">{g.items.length} 项</span></div>
|
||||
{g.items.map((item) => (
|
||||
<div key={item.id} className={'mv-card' + (item.completed ? ' done' : '')}>
|
||||
<RingProgress
|
||||
size={42}
|
||||
stroke={5}
|
||||
percent={item.percent}
|
||||
done={item.done}
|
||||
target={item.target}
|
||||
over={item.over}
|
||||
completed={item.completed}
|
||||
color={item.color}
|
||||
onClick={() => doCheck(item)}
|
||||
/>
|
||||
<div className="mv-main">
|
||||
<div className="mv-name">{item.name}</div>
|
||||
<div className="mv-meta">
|
||||
{item.periodLabel} {item.target} 次 · 当日 {item.countOnDate || 0}/{item.expectedPerDay}
|
||||
{item.streak > 0 && <span className="st">连续 {item.streak}</span>}
|
||||
</div>
|
||||
</div>
|
||||
{isFuture ? (
|
||||
<div className="mv-btn grey">计划</div>
|
||||
) : (
|
||||
<div
|
||||
className={'mv-btn' + (item.completed ? ' grey' : '')}
|
||||
onClick={() => doCheck(item)}
|
||||
>
|
||||
{item.completed ? '再打一次' : '打卡'}
|
||||
</div>
|
||||
)}
|
||||
<div className="mv-more" onClick={() => setSheetItem(item)}>⋯</div>
|
||||
<Celebrate fire={bursts[item.id] || 0} color={item.color} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 「⋯」操作面板: 撤销 / 编辑 / 归档 */}
|
||||
<Popup
|
||||
visible={!!sheetItem}
|
||||
onMaskClick={() => setSheetItem(null)}
|
||||
bodyStyle={{ borderTopLeftRadius: 12, borderTopRightRadius: 12, paddingBottom: 16 }}
|
||||
>
|
||||
{sheetItem && (
|
||||
<div className="mv-sheet">
|
||||
<div className="sheet-title">{sheetItem.name}</div>
|
||||
<div
|
||||
className={'mv-sheet-row' + (sheetItem.done > 0 ? '' : ' disabled')}
|
||||
onClick={() => { if (sheetItem.done > 0) { doUncheck(sheetItem); setSheetItem(null) } }}
|
||||
>
|
||||
<span>撤销上次打卡</span>
|
||||
<span className="k">今日 {sheetItem.countOnDate || 0} 次</span>
|
||||
</div>
|
||||
<div className="mv-sheet-row" onClick={() => openEdit(sheetItem)}>
|
||||
<span>编辑打卡项</span>
|
||||
<span className="k">名称 / 周期 / 目标</span>
|
||||
</div>
|
||||
<div className="mv-sheet-row danger" onClick={() => doArchive(sheetItem)}>
|
||||
<span>归档</span>
|
||||
<span className="k">历史记录保留</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Popup>
|
||||
|
||||
{/* 新建 / 编辑表单 */}
|
||||
<Popup
|
||||
visible={showForm}
|
||||
onMaskClick={() => setShowForm(false)}
|
||||
bodyStyle={{ borderTopLeftRadius: 12, borderTopRightRadius: 12, paddingBottom: 16 }}
|
||||
>
|
||||
<div className="form-popup">
|
||||
<div className="form-title">{form.id ? '编辑打卡项' : '新建打卡项'}</div>
|
||||
<div className="form-field">
|
||||
<label>名称</label>
|
||||
<Input
|
||||
placeholder="例如:喝水 / 健身 / 读书"
|
||||
value={form.name}
|
||||
maxLength={20}
|
||||
onChange={(v) => setForm((f) => ({ ...f, name: v }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label>周期</label>
|
||||
<Selector
|
||||
options={PERIOD_OPTIONS}
|
||||
value={[form.period]}
|
||||
onChange={(v) => setForm((f) => ({ ...f, period: v[0] }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label>目标次数(周期内)</label>
|
||||
<Stepper min={1} max={999} value={form.target} onChange={(v) => setForm((f) => ({ ...f, target: v }))} />
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label>颜色</label>
|
||||
<div className="color-row">
|
||||
{COLORS.map((c) => (
|
||||
<span
|
||||
key={c}
|
||||
className={'color-dot' + (form.color === c ? ' active' : '')}
|
||||
style={{ background: c }}
|
||||
onClick={() => setForm((f) => ({ ...f, color: c }))}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label>备注(可选)</label>
|
||||
<Input
|
||||
placeholder="补充说明"
|
||||
value={form.note}
|
||||
onChange={(v) => setForm((f) => ({ ...f, note: v }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-actions">
|
||||
<Button block color="primary" onClick={submitForm}>保存</Button>
|
||||
<Button block fill="outline" onClick={() => setShowForm(false)} style={{ marginTop: 8 }}>取消</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Popup>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
// 打卡 · 日历与打卡合并视图 (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 (
|
||||
<div className="mv-view">
|
||||
{/* 月历 */}
|
||||
<div className="mv-cal">
|
||||
<div className="mv-month">
|
||||
<span className="arrow" onClick={() => setMonth((m) => addMonths(m, -1))}>‹</span>
|
||||
<b>{fmtMonth(month)}</b>
|
||||
<span className="arrow" onClick={() => setMonth((m) => addMonths(m, 1))}>›</span>
|
||||
<span className="today" onClick={() => onDateChange(today)}>回到今天</span>
|
||||
</div>
|
||||
|
||||
<div className="mv-grid mv-wd">
|
||||
{weekdayLabels().map((w) => <span key={w}>{w}</span>)}
|
||||
</div>
|
||||
<div className="mv-grid">
|
||||
{days.map((d) => {
|
||||
const info = byDate.get(d)
|
||||
const inMonth = d.slice(0, 7) === month
|
||||
const status = info?.status || 'empty'
|
||||
return (
|
||||
<div
|
||||
key={d}
|
||||
className={
|
||||
'mv-day' + (inMonth ? '' : ' out') + (d === date ? ' sel' : '') +
|
||||
(info?.isToday ? ' today' : '') + (info?.isFuture ? ' future' : '')
|
||||
}
|
||||
onClick={() => onDateChange(d)}
|
||||
>
|
||||
<span>{Number(d.slice(8, 10))}</span>
|
||||
{inMonth && status !== 'empty' && <i className={status} />}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{cal && (
|
||||
<div className="mv-rate">
|
||||
<span>本月完成率</span>
|
||||
<b>{Math.round((cal.rate || 0) * 100)}%</b>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{err && <div className="error-banner">{err}<span className="retry-link" onClick={() => { loadCal(false); loadDetail(false) }}>重试</span></div>}
|
||||
|
||||
{/* 当日栏: 右侧放「+ 新建」, 替代原来无实际作用的「点环即打卡」提示 */}
|
||||
<div className="mv-daybar">
|
||||
{dayLabel(date, detail?.today || today)}
|
||||
{isFuture
|
||||
? <span className="badge">计划(不可打卡)</span>
|
||||
: <span className="badge">已完成 {summary.done} / {summary.total}</span>}
|
||||
<Button
|
||||
className="mv-new-btn"
|
||||
size="mini"
|
||||
color="primary"
|
||||
onClick={openCreate}
|
||||
>
|
||||
+ 新建
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{detail === null && !err && <div className="monitor-loading"><DotLoading /> 加载中...</div>}
|
||||
|
||||
{detail !== null && items.length === 0 && (
|
||||
<div className="checkin-empty">
|
||||
<Empty description="当天没有打卡项" />
|
||||
<Button color="primary" size="small" onClick={openCreate}>创建第一个打卡项</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 分组列表 */}
|
||||
<div className="mv-groups">
|
||||
{groups.map((g) => (
|
||||
<div key={g.key}>
|
||||
<div className="mv-ghead">{g.label}<span className="cnt">{g.items.length} 项</span></div>
|
||||
{g.items.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
data-habit-row
|
||||
data-habit-id={item.id}
|
||||
data-habit-period={item.period}
|
||||
className={
|
||||
'mv-card'
|
||||
+ (item.completed ? ' done' : '')
|
||||
+ (drag.dragItem?.id === item.id ? ' dragging' : '')
|
||||
+ (drag.over?.id === item.id ? ` dragover ${drag.over.after ? 'after' : 'before'}` : '')
|
||||
}
|
||||
>
|
||||
<span className="grip" title="拖动排序" {...drag.bindHandle(item)}>⠿</span>
|
||||
<RingProgress
|
||||
size={42}
|
||||
stroke={5}
|
||||
percent={item.percent}
|
||||
done={item.done}
|
||||
target={item.target}
|
||||
over={item.over}
|
||||
completed={item.completed}
|
||||
color={item.color}
|
||||
onClick={() => { if (!drag.justEnded()) doCheck(item) }}
|
||||
/>
|
||||
<div className="mv-main">
|
||||
<div className="mv-name">{item.name}</div>
|
||||
<div className="mv-meta">
|
||||
{item.periodLabel} {item.target} 次 · 当日 {item.countOnDate || 0}/{item.expectedPerDay}
|
||||
{item.streak > 0 && <span className="st">连续 {item.streak}</span>}
|
||||
</div>
|
||||
</div>
|
||||
{isFuture ? (
|
||||
<div className="mv-btn grey">计划</div>
|
||||
) : (
|
||||
<div
|
||||
className={'mv-btn' + (item.completed ? ' grey' : '')}
|
||||
onClick={() => { if (!drag.justEnded()) doCheck(item) }}
|
||||
>
|
||||
{item.completed ? '再打一次' : '打卡'}
|
||||
</div>
|
||||
)}
|
||||
<div className="mv-more" onClick={() => setSheetItem(item)}>⋯</div>
|
||||
<Celebrate fire={bursts[item.id] || 0} color={item.color} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 「⋯」操作面板: 撤销 / 编辑 / 归档 */}
|
||||
<Popup
|
||||
visible={!!sheetItem}
|
||||
onMaskClick={() => setSheetItem(null)}
|
||||
bodyStyle={{ borderTopLeftRadius: 12, borderTopRightRadius: 12, paddingBottom: 16 }}
|
||||
>
|
||||
{sheetItem && (
|
||||
<div className="mv-sheet">
|
||||
<div className="sheet-title">{sheetItem.name}</div>
|
||||
<div
|
||||
className={'mv-sheet-row' + (sheetItem.done > 0 ? '' : ' disabled')}
|
||||
onClick={() => { if (sheetItem.done > 0) { doUncheck(sheetItem); setSheetItem(null) } }}
|
||||
>
|
||||
<span>撤销上次打卡</span>
|
||||
<span className="k">今日 {sheetItem.countOnDate || 0} 次</span>
|
||||
</div>
|
||||
<div className="mv-sheet-row" onClick={() => openEdit(sheetItem)}>
|
||||
<span>编辑打卡项</span>
|
||||
<span className="k">名称 / 周期 / 目标</span>
|
||||
</div>
|
||||
<div className="mv-sheet-row danger" onClick={() => doArchive(sheetItem)}>
|
||||
<span>归档</span>
|
||||
<span className="k">历史记录保留</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Popup>
|
||||
|
||||
{/* 新建 / 编辑表单 */}
|
||||
<Popup
|
||||
visible={showForm}
|
||||
onMaskClick={() => setShowForm(false)}
|
||||
bodyStyle={{ borderTopLeftRadius: 12, borderTopRightRadius: 12, paddingBottom: 16 }}
|
||||
>
|
||||
<div className="form-popup">
|
||||
<div className="form-title">{form.id ? '编辑打卡项' : '新建打卡项'}</div>
|
||||
<div className="form-field">
|
||||
<label>名称</label>
|
||||
<Input
|
||||
placeholder="例如:喝水 / 健身 / 读书"
|
||||
value={form.name}
|
||||
maxLength={20}
|
||||
onChange={(v) => setForm((f) => ({ ...f, name: v }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label>周期</label>
|
||||
<Selector
|
||||
options={PERIOD_OPTIONS}
|
||||
value={[form.period]}
|
||||
onChange={(v) => setForm((f) => ({ ...f, period: v[0] }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label>目标次数(周期内)</label>
|
||||
<Stepper min={1} max={999} value={form.target} onChange={(v) => setForm((f) => ({ ...f, target: v }))} />
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label>颜色</label>
|
||||
<div className="color-row">
|
||||
{COLORS.map((c) => (
|
||||
<span
|
||||
key={c}
|
||||
className={'color-dot' + (form.color === c ? ' active' : '')}
|
||||
style={{ background: c }}
|
||||
onClick={() => setForm((f) => ({ ...f, color: c }))}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label>备注(可选)</label>
|
||||
<Input
|
||||
placeholder="补充说明"
|
||||
value={form.note}
|
||||
onChange={(v) => setForm((f) => ({ ...f, note: v }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-actions">
|
||||
<Button block color="primary" onClick={submitForm}>保存</Button>
|
||||
<Button block fill="outline" onClick={() => setShowForm(false)} style={{ marginTop: 8 }}>取消</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Popup>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<div className="bcol-main">
|
||||
<div className="bcol-body" ref={ref} onScroll={onScroll}>{children}</div>
|
||||
<div className={'bcol-bar' + (bar ? ' on' : '')} style={bar ? { top: bar.y, height: bar.h } : undefined} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 看板视图 (T-BD-01, T-UI-07)
|
||||
// 拖动 = 长按 400ms 激活, 卡片原位抬起, 指针划过列高亮, 松手落列 (重点设计 §7.1)
|
||||
// 横向滚列; 列内独立纵向滚动, 列头固定, 滚动时右缘显示滚动条
|
||||
// 不做"拖到卡片上转子任务" —— 统一走详情页「转为子任务」
|
||||
export default function BoardView({
|
||||
tasks, columns, onMoveColumn, onOpen, onToggleSub, onToggle, onMore, onQuickAdd,
|
||||
@@ -33,14 +64,14 @@ export default function BoardView({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="bcol-body">
|
||||
<ScrollBody>
|
||||
{list.map((t) => (
|
||||
<TaskCard
|
||||
key={t.id}
|
||||
task={t}
|
||||
dragging={drag.dragTask?.id === t.id}
|
||||
bindDrag={drag.bind}
|
||||
onClick={() => { 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 && <div className="bcol-empty">暂无任务</div>}
|
||||
<div className="badd" onClick={() => onQuickAdd?.(col.id)}>+ 添加任务</div>
|
||||
</div>
|
||||
</ScrollBody>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -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 (
|
||||
<div className="list-body">
|
||||
{groups.map((g) => (
|
||||
<div key={g.key}>
|
||||
<div key={g.key} data-column-id={g.key === 'other' ? undefined : g.key}>
|
||||
<div className="group-title">
|
||||
{g.name}
|
||||
<span className="cnt">{g.list.length}</span>
|
||||
@@ -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 (
|
||||
<div key={t.id}>
|
||||
<TaskRow
|
||||
task={t}
|
||||
onToggle={onToggle}
|
||||
onClick={() => 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) => (
|
||||
<TaskRow key={c.id} task={c} indent onToggle={onToggle} onClick={() => onOpen(c)} onMore={onMore} onLongPress={onLongPress} />
|
||||
<TaskRow key={c.id} task={c} indent onToggle={onToggle} onClick={() => onOpen(c)} onMore={onMore} />
|
||||
))}
|
||||
<div
|
||||
className="collapse"
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { useState } from 'react'
|
||||
import { useRef, useState } from 'react'
|
||||
import { Dialog, Input, Popup, Toast } from 'antd-mobile'
|
||||
import { projectApi } from '../../api/task.js'
|
||||
import { useAuth } from '../../auth/AuthContext.jsx'
|
||||
import useKeyboardInset from '../../hooks/useKeyboardInset.js'
|
||||
import useOverlayBack from '../../hooks/useOverlayBack.js'
|
||||
import ColumnSettings from './ColumnSettings.jsx'
|
||||
import ProjectMembers from './ProjectMembers.jsx'
|
||||
import { DEFAULT_KINDS, kindColor } from './kind.js'
|
||||
import { colorOfProject } from './projectIcon.js'
|
||||
|
||||
// 项目管理 (T-PJ-11/12): 成员 / 看板列 / 项目信息 三个分区的统一入口
|
||||
// onExited: 项目被删除/退出/归档后, 上层应离开该项目(回落个人默认项目)
|
||||
@@ -15,6 +18,12 @@ export default function ProjectManage({ project, onClose, onChanged, onExited })
|
||||
const [name, setName] = useState(project.name)
|
||||
// 信息块编辑 (T-PJ-13): index=-1 新增, >=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 })
|
||||
<span className="link">列设置 ›</span>
|
||||
</div>
|
||||
|
||||
<div className="section-h" style={{ margin: '14px 14px 8px' }}>任务类型</div>
|
||||
<div className="set-row" style={{ padding: '12px 14px' }} onClick={() => { setKinds(project.taskKinds || DEFAULT_KINDS); setNewKind(''); setKindEdit(true) }}>
|
||||
<div className="kind-chip-row" style={{ flex: 1 }}>
|
||||
{(project.taskKinds || DEFAULT_KINDS).map((k) => (
|
||||
<span key={k} className="kind-tag" style={{ background: kindColor(k) }}>{k}</span>
|
||||
))}
|
||||
</div>
|
||||
<span className="link">编辑 ›</span>
|
||||
</div>
|
||||
|
||||
<div className="section-h" style={{ margin: '14px 14px 8px' }}>信息块({notes.length})</div>
|
||||
{notes.map((n, i) => (
|
||||
<div
|
||||
@@ -184,6 +268,25 @@ export default function ProjectManage({ project, onClose, onChanged, onExited })
|
||||
</div>
|
||||
|
||||
<div className="section-h" style={{ margin: '14px 14px 8px' }}>项目信息</div>
|
||||
<div className="set-row" style={{ padding: '12px 14px' }}>
|
||||
<span className="pn" style={{ flex: 1, fontSize: 13.5 }}>项目图标</span>
|
||||
<div
|
||||
className="pavatar"
|
||||
style={{
|
||||
width: 34, height: 34, borderRadius: 9, fontSize: 14,
|
||||
background: project.icon
|
||||
? `url(${project.icon}) center/cover no-repeat`
|
||||
: colorOfProject(project),
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
onClick={pickIcon}
|
||||
>
|
||||
{project.icon ? '' : (project.name || '?').slice(0, 1)}
|
||||
</div>
|
||||
{project.icon && <span className="link" style={{ marginLeft: 10 }} onClick={clearIcon}>恢复</span>}
|
||||
<span className="link" style={{ marginLeft: 10 }} onClick={pickIcon}>更换</span>
|
||||
<input ref={iconInputRef} type="file" accept="image/*" hidden onChange={onIconFile} />
|
||||
</div>
|
||||
<div className="set-row" style={{ padding: '12px 14px' }} onClick={() => { setName(project.name); setRenaming(true) }}>
|
||||
<span className="pn" style={{ flex: 1, fontSize: 13.5 }}>项目名称</span>
|
||||
<span style={{ fontSize: 12.5, color: '#999' }}>{project.name}</span>
|
||||
@@ -225,12 +328,60 @@ export default function ProjectManage({ project, onClose, onChanged, onExited })
|
||||
onClose={() => setRenaming(false)}
|
||||
/>
|
||||
|
||||
{/* 任务类型编辑: chips 删除 + 输入新增 */}
|
||||
<Popup
|
||||
visible={kindEdit}
|
||||
onMaskClick={() => setKindEdit(false)}
|
||||
bodyStyle={{
|
||||
borderTopLeftRadius: 12,
|
||||
borderTopRightRadius: 12,
|
||||
padding: `16px 16px calc(env(safe-area-inset-bottom, 0px) + 20px + ${kb}px)`,
|
||||
}}
|
||||
>
|
||||
<div className="sheet-title">任务类型</div>
|
||||
<div className="sheet-sub">任务的分类标签,显示在任务卡片左下角</div>
|
||||
|
||||
<div className="kind-chip-row" style={{ marginTop: 12 }}>
|
||||
{kinds.map((k) => (
|
||||
<span key={k} className="kind-chip" style={{ background: kindColor(k) }}>
|
||||
{k}
|
||||
<span
|
||||
className="x"
|
||||
onClick={() => setKinds((p) => p.filter((x) => x !== k))}
|
||||
>×</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="extra-row" style={{ display: 'flex', gap: 8, marginTop: 14 }}>
|
||||
<input
|
||||
className="inp"
|
||||
style={{ flex: 1 }}
|
||||
placeholder="新类型名称"
|
||||
value={newKind}
|
||||
maxLength={20}
|
||||
onChange={(e) => setNewKind(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') addKind() }}
|
||||
/>
|
||||
<span className="mini-btn solid" style={{ padding: '0 16px', display: 'inline-flex', alignItems: 'center' }} onClick={addKind}>添加</span>
|
||||
</div>
|
||||
|
||||
<div className="btn-primary" onClick={saveKinds}>保存</div>
|
||||
<div className="btn-ghost" onClick={() => setKindEdit(false)}>取消</div>
|
||||
</Popup>
|
||||
|
||||
{/* 信息块编辑 (T-PJ-13): 标题 + 长文本, 独立编辑 */}
|
||||
<Popup
|
||||
visible={!!noteEdit}
|
||||
onMaskClick={() => 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)`,
|
||||
}}
|
||||
>
|
||||
<div className="sheet-title">{noteEdit?.index >= 0 ? '编辑信息块' : '添加信息块'}</div>
|
||||
<div className="sheet-sub">用于记录 Readme 等项目相关长文本,每块独立编辑</div>
|
||||
@@ -249,7 +400,6 @@ export default function ProjectManage({ project, onClose, onChanged, onExited })
|
||||
<textarea
|
||||
className="inp"
|
||||
rows={10}
|
||||
onFocus={(e) => { setTimeout(() => e.target.scrollIntoView({ block: 'center', behavior: 'smooth' }), 300) }}
|
||||
placeholder="支持多行长文本…"
|
||||
value={noteEdit?.content || ''}
|
||||
onChange={(e) => setNoteEdit((p) => ({ ...(p || {}), content: e.target.value }))}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ActionSheet, Dialog, DotLoading, Toast } from 'antd-mobile'
|
||||
import { projectApi, taskApi } from '../../api/task.js'
|
||||
import { filterVisible, msToNextBoundary } from './date.js'
|
||||
import { getViewMode, setViewMode } from './prefs.js'
|
||||
import { parseTimePrefix, splitTitle } from './quickAdd.js'
|
||||
import ListView from './ListView.jsx'
|
||||
import BoardView from './BoardView.jsx'
|
||||
import TaskDetail from './TaskDetail.jsx'
|
||||
@@ -102,6 +103,33 @@ export default function ProjectView({ projectId, onProjectChanged, onProjectExit
|
||||
} catch { /* 已回滚并提示 */ }
|
||||
}
|
||||
|
||||
/** 组内拖动排序: 按分组顺序展平重写 sortOrder (只动顶层任务) */
|
||||
const reorderTask = async (task, targetId, after) => {
|
||||
const snapshot = tasks
|
||||
const orderedCols = [...(columns || [])].sort((a, b) => a.sortOrder - b.sortOrder)
|
||||
const groups = orderedCols.map((c) => (tasks || []).filter((t) => t.columnId === c.id)).filter((l) => l.length)
|
||||
const g = groups.find((l) => l.some((t) => t.id === task.id))
|
||||
if (!g) return
|
||||
const rest = g.filter((t) => t.id !== task.id)
|
||||
const ti = rest.findIndex((t) => t.id === targetId)
|
||||
if (ti < 0) return
|
||||
rest.splice(after ? ti + 1 : ti, 0, task)
|
||||
const flat = groups.map((l) => (l === g ? rest : l)).flat()
|
||||
const flatIds = new Set(flat.map((t) => t.id))
|
||||
// 乐观更新必须重排数组本身 —— 列表分组渲染按数组顺序,
|
||||
// 只改 sortOrder 字段界面不会动 (打卡侧即按此口径)
|
||||
setTasks((prev) => [
|
||||
...flat.map((t, i) => ({ ...t, sortOrder: i })),
|
||||
...(prev || []).filter((t) => !flatIds.has(t.id)),
|
||||
])
|
||||
try {
|
||||
await taskApi.reorder(projectId, flat.map((t) => t.id))
|
||||
} catch (e) {
|
||||
setTasks(snapshot)
|
||||
Toast.show({ content: e.message || '排序保存失败' })
|
||||
}
|
||||
}
|
||||
|
||||
/** 改状态 (T-BD-14~16) */
|
||||
const changeStatus = async (task, status) => {
|
||||
try {
|
||||
@@ -114,12 +142,21 @@ export default function ProjectView({ projectId, onProjectChanged, onProjectExit
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
/** 新建任务 */
|
||||
async function createTask(title, parentId = null) {
|
||||
const t = String(title || '').trim()
|
||||
if (!t) return
|
||||
/** 新建任务 (快速添加): 时间词前缀 → 时间标记; 超过 30 字在分隔符处截断,
|
||||
* 前段为标题, 余文并入描述 (T-TK-14/16) */
|
||||
async function createTask(rawTitle, parentId = null) {
|
||||
const raw = String(rawTitle || '').trim()
|
||||
if (!raw) return
|
||||
const { markDate, rest } = parseTimePrefix(raw)
|
||||
const { title, desc } = splitTitle(rest)
|
||||
if (!title) return
|
||||
try {
|
||||
await taskApi.create(projectId, { title: t, parentId })
|
||||
await taskApi.create(projectId, {
|
||||
title,
|
||||
description: desc || undefined,
|
||||
markDate: markDate || undefined,
|
||||
parentId,
|
||||
})
|
||||
setAdding(false)
|
||||
load()
|
||||
} catch (e) {
|
||||
@@ -127,24 +164,9 @@ export default function ProjectView({ projectId, onProjectChanged, onProjectExit
|
||||
}
|
||||
}
|
||||
|
||||
/** 长按任务 → 移动到列 (重点设计 §7.1: 列表页不做自由拖动, 菜单可靠) */
|
||||
function openMoveMenu(task) {
|
||||
ActionSheet.show({
|
||||
actions: (columns || []).map((c) => ({
|
||||
text: c.name + (c.id === task.columnId ? ' ✓' : ''),
|
||||
key: String(c.id),
|
||||
})),
|
||||
cancelText: '取消',
|
||||
onAction: (item) => {
|
||||
const columnId = Number(item.key)
|
||||
if (columnId !== task.columnId) moveToColumn(task, columnId)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/** 任务 ⋯ 菜单: 编辑 / 拷贝到其他项目 / 删除 */
|
||||
/** 任务 ⋯ 菜单: 编辑 / 拷贝到其他项目 / 删除 (点选后菜单收回) */
|
||||
function openTaskMenu(task) {
|
||||
ActionSheet.show({
|
||||
const handler = ActionSheet.show({
|
||||
actions: [
|
||||
{ text: '编辑', key: 'edit' },
|
||||
{ text: '拷贝到其他项目', key: 'copy' },
|
||||
@@ -152,6 +174,7 @@ export default function ProjectView({ projectId, onProjectChanged, onProjectExit
|
||||
],
|
||||
cancelText: '取消',
|
||||
onAction: async (item) => {
|
||||
handler.close?.()
|
||||
if (item.key === 'edit') setOpenTask(task)
|
||||
else if (item.key === 'copy') setCopyTask(task)
|
||||
else if (item.key === 'delete') {
|
||||
@@ -217,12 +240,15 @@ export default function ProjectView({ projectId, onProjectChanged, onProjectExit
|
||||
|
||||
{adding && (
|
||||
<div className="quick-add">
|
||||
<input
|
||||
<textarea
|
||||
className="inp"
|
||||
autoFocus
|
||||
placeholder="输入任务标题,回车创建"
|
||||
rows={3}
|
||||
maxLength={200}
|
||||
placeholder={'输入任务标题,回车创建;开头可用 今天/明天/本周/本月 等时间词\n超过 30 字自动截断为标题,全文进描述'}
|
||||
onBlur={(e) => { if (!e.currentTarget.value.trim()) setAdding(false) }}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') createTask(e.currentTarget.value)
|
||||
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); createTask(e.currentTarget.value) }
|
||||
if (e.key === 'Escape') setAdding(false)
|
||||
}}
|
||||
/>
|
||||
@@ -236,7 +262,8 @@ export default function ProjectView({ projectId, onProjectChanged, onProjectExit
|
||||
onToggle={toggleDone}
|
||||
onOpen={(t) => setOpenTask(t)}
|
||||
onMore={openTaskMenu}
|
||||
onLongPress={openMoveMenu}
|
||||
onMoveColumn={moveToColumn}
|
||||
onReorder={reorderTask}
|
||||
/>
|
||||
) : (
|
||||
<BoardView
|
||||
@@ -258,6 +285,7 @@ export default function ProjectView({ projectId, onProjectChanged, onProjectExit
|
||||
columns={columns}
|
||||
members={project.members || []}
|
||||
tasks={tasks || []}
|
||||
taskKinds={project.taskKinds}
|
||||
onClose={() => setOpenTask(null)}
|
||||
onSaved={() => { setOpenTask(null); load() }}
|
||||
onChanged={load}
|
||||
|
||||
@@ -1,30 +1,38 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { ActionSheet, Popup, Toast } from 'antd-mobile'
|
||||
import { ActionSheet, Popup, Toast, DatePicker } from 'antd-mobile'
|
||||
import { taskApi } from '../../api/task.js'
|
||||
import { STATUS_OPTIONS, STATUS_LABEL } from './date.js'
|
||||
import { kindColor } from './kind.js'
|
||||
import { parseTimePrefix, splitTitle } from './quickAdd.js'
|
||||
import UserPicker from '../../components/task/UserPicker.jsx'
|
||||
import StatusDot from '../../components/task/StatusDot.jsx'
|
||||
import useKeyboardInset from '../../hooks/useKeyboardInset.js'
|
||||
import useOverlayBack from '../../hooks/useOverlayBack.js'
|
||||
|
||||
// 任务详情 (T-TK-*, T-ST-03, T-AS-05)
|
||||
// - 状态: 三段式分段控件, 当前项高亮, 点按即切换
|
||||
// - 看板列: ActionSheet 选择, 列/状态联动由服务端裁决 (T-BD-*)
|
||||
// - 转为子任务 / 转为顶层任务 (T-ST-12): 从项目内顶层任务中选父任务
|
||||
export default function TaskDetail({ task, columns, members, tasks, onClose, onSaved, onChanged }) {
|
||||
export default function TaskDetail({ task, columns, members, tasks, taskKinds, onClose, onSaved, onChanged }) {
|
||||
const [form, setForm] = useState(() => ({
|
||||
title: task.title || '',
|
||||
description: task.description || '',
|
||||
memberIds: (task.members || []).map((m) => m.userId),
|
||||
assigneeId: task.assignee?.id ?? null,
|
||||
status: task.status,
|
||||
kind: task.kind || null,
|
||||
markDate: task.markDate ? task.markDate.slice(0, 10) : null,
|
||||
tags: task.tags || [],
|
||||
}))
|
||||
const [subs, setSubs] = useState(task.children || [])
|
||||
const [newSub, setNewSub] = useState('')
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [picker, setPicker] = useState(null) // 'assignee' | 'members'
|
||||
const [pickDate, setPickDate] = useState(false) // 时间标记日期选择
|
||||
const [parentPick, setParentPick] = useState(false) // 转为子任务: 选择父任务
|
||||
|
||||
// 键盘弹出插入量: 抬升弹层内容, 输入框不被遮挡
|
||||
const kb = useKeyboardInset()
|
||||
// 返回键: 先关详情弹层, 不离开任务页
|
||||
useOverlayBack(true, onClose)
|
||||
|
||||
@@ -44,6 +52,8 @@ export default function TaskDetail({ task, columns, members, tasks, onClose, onS
|
||||
memberIds: (task.members || []).map((m) => m.userId),
|
||||
assigneeId: task.assignee?.id ?? null,
|
||||
status: task.status,
|
||||
kind: task.kind || null,
|
||||
markDate: task.markDate ? task.markDate.slice(0, 10) : null,
|
||||
tags: task.tags || [],
|
||||
})
|
||||
setSubs(task.children || [])
|
||||
@@ -59,6 +69,8 @@ export default function TaskDetail({ task, columns, members, tasks, onClose, onS
|
||||
assigneeId: form.assigneeId,
|
||||
memberIds: form.memberIds,
|
||||
status: form.status,
|
||||
kind: form.kind,
|
||||
markDate: form.markDate || null,
|
||||
tags: form.tags,
|
||||
})
|
||||
Toast.show({ content: '已保存' })
|
||||
@@ -104,10 +116,19 @@ export default function TaskDetail({ task, columns, members, tasks, onClose, onS
|
||||
}
|
||||
|
||||
async function addSub() {
|
||||
const t = newSub.trim()
|
||||
if (!t) return
|
||||
// 子任务快速添加同样支持时间词前缀 + 30 字分隔符截断 (T-TK-14/16)
|
||||
const raw = newSub.trim()
|
||||
if (!raw) return
|
||||
const { markDate, rest } = parseTimePrefix(raw)
|
||||
const { title, desc } = splitTitle(rest)
|
||||
if (!title) return
|
||||
try {
|
||||
const r = await taskApi.create(task.projectId, { title: t, parentId: task.id })
|
||||
const r = await taskApi.create(task.projectId, {
|
||||
title,
|
||||
description: desc || undefined,
|
||||
markDate: markDate || undefined,
|
||||
parentId: task.id,
|
||||
})
|
||||
setSubs((prev) => [...prev, r.item])
|
||||
setNewSub('')
|
||||
onChanged?.()
|
||||
@@ -127,12 +148,17 @@ export default function TaskDetail({ task, columns, members, tasks, onClose, onS
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Popup
|
||||
return ( <Popup
|
||||
visible
|
||||
onMaskClick={onClose}
|
||||
position="bottom"
|
||||
className="kb-safe"
|
||||
bodyStyle={{
|
||||
borderTopLeftRadius: 12,
|
||||
borderTopRightRadius: 12,
|
||||
maxHeight: `calc(88vh - ${kb}px)`,
|
||||
overflowY: 'auto',
|
||||
padding: `16px 16px calc(env(safe-area-inset-bottom, 0px) + 20px + ${kb}px)`,
|
||||
}}
|
||||
>
|
||||
<div className="sheet-title">{task.title}</div>
|
||||
<div className="sheet-sub">
|
||||
@@ -144,7 +170,7 @@ export default function TaskDetail({ task, columns, members, tasks, onClose, onS
|
||||
<input
|
||||
className="inp"
|
||||
value={form.title}
|
||||
maxLength={200}
|
||||
maxLength={30}
|
||||
onChange={(e) => setForm((p) => ({ ...p, title: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
@@ -176,6 +202,61 @@ export default function TaskDetail({ task, columns, members, tasks, onClose, onS
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 时间标记: 可选日期, 卡片上以 MM-dd 显示 */}
|
||||
<div className="field">
|
||||
<label>时间标记</label>
|
||||
<div className="pick-row">
|
||||
<span className="pn">{form.markDate ? form.markDate.slice(5).replace('-', '/') : '未设置'}</span>
|
||||
{form.markDate && (
|
||||
<span className="link" onClick={() => setForm((p) => ({ ...p, markDate: null }))}>清除</span>
|
||||
)}
|
||||
<span className="link" onClick={() => setPickDate(true)}>选择 ›</span>
|
||||
</div>
|
||||
<DatePicker
|
||||
visible={pickDate}
|
||||
onClose={() => setPickDate(false)}
|
||||
precision="day"
|
||||
value={form.markDate ? new Date(form.markDate + 'T00:00:00') : new Date()}
|
||||
onConfirm={(d) => {
|
||||
const p2 = (n) => String(n).padStart(2, '0')
|
||||
setForm((p) => ({ ...p, markDate: `${d.getFullYear()}-${p2(d.getMonth() + 1)}-${p2(d.getDate())}` }))
|
||||
setPickDate(false)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 类型: 项目任务类型标签, 可清空 */}
|
||||
{(taskKinds || []).length > 0 && (
|
||||
<div className="field">
|
||||
<label>类型</label>
|
||||
<div className="kind-chip-row" style={{ flexWrap: 'wrap' }}>
|
||||
<span
|
||||
className="kind-tag"
|
||||
style={{
|
||||
background: form.kind ? 'transparent' : 'var(--color-primary)',
|
||||
color: form.kind ? '#999' : '#fff',
|
||||
border: form.kind ? '1px dashed #ccc' : 'none',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
onClick={() => setForm((p) => ({ ...p, kind: null }))}
|
||||
>无</span>
|
||||
{taskKinds.map((k) => (
|
||||
<span
|
||||
key={k}
|
||||
className="kind-tag"
|
||||
style={{
|
||||
background: kindColor(k),
|
||||
opacity: form.kind === k ? 1 : 0.45,
|
||||
cursor: 'pointer',
|
||||
outline: form.kind === k ? '2px solid rgba(22,119,255,.4)' : 'none',
|
||||
}}
|
||||
onClick={() => setForm((p) => ({ ...p, kind: p.kind === k ? null : k }))}
|
||||
>{k}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 看板列: 点击换列, 状态随目标列联动 */}
|
||||
<div className="field">
|
||||
<label>看板列</label>
|
||||
@@ -273,8 +354,11 @@ export default function TaskDetail({ task, columns, members, tasks, onClose, onS
|
||||
<Popup
|
||||
visible={parentPick}
|
||||
onMaskClick={() => setParentPick(false)}
|
||||
className="kb-safe"
|
||||
bodyStyle={{ maxHeight: '70vh' }}
|
||||
bodyStyle={{
|
||||
borderTopLeftRadius: 12, borderTopRightRadius: 12,
|
||||
maxHeight: '70vh', overflowY: 'auto',
|
||||
padding: '16px 16px calc(env(safe-area-inset-bottom, 0px) + 20px)',
|
||||
}}
|
||||
>
|
||||
<div className="sheet-title">转为子任务</div>
|
||||
<div className="sheet-sub">选择父任务(仅支持一级;已有子任务的任务不能作为父任务)</div>
|
||||
|
||||
@@ -126,8 +126,7 @@ export default function TaskPage() {
|
||||
)
|
||||
}
|
||||
|
||||
const COLORS = ['#1677ff', '#00b578', '#ff8f1f', '#a855f7', '#14b8a6', '#f43f9e']
|
||||
const colorOf = (p) => COLORS[Math.abs(p.id) % COLORS.length]
|
||||
import { colorOfProject } from './projectIcon.js'
|
||||
const LONG_PRESS = 300
|
||||
const MOVE_TOLERANCE = 8
|
||||
|
||||
@@ -286,8 +285,13 @@ function ProjectList({ projects, err, onRetry, onOpen, onChanged, createSignal,
|
||||
onMouseDown={(ev) => beginPress(p, ev)}
|
||||
onClick={() => { if (!activeRef.current && !dragId) onOpen(p.id) }}
|
||||
>
|
||||
<div className="pico" style={{ background: colorOf(p) }}>
|
||||
{(p.name || '?').slice(0, 1)}
|
||||
<div
|
||||
className="pico"
|
||||
style={p.icon
|
||||
? { background: `url(${p.icon}) center/cover no-repeat` }
|
||||
: { background: colorOfProject(p) }}
|
||||
>
|
||||
{p.icon ? '' : (p.name || '?').slice(0, 1)}
|
||||
</div>
|
||||
<div className="pmain">
|
||||
<div className="ptitle">
|
||||
@@ -315,7 +319,14 @@ function ProjectList({ projects, err, onRetry, onOpen, onChanged, createSignal,
|
||||
<div className="proj-group">已归档({archived.length})</div>
|
||||
{archived.map((p) => (
|
||||
<div className="proj-item" key={p.id} style={{ opacity: 0.65 }} onClick={() => onOpen(p.id)}>
|
||||
<div className="pico" style={{ background: '#a8b0ba' }}>{(p.name || '?').slice(0, 1)}</div>
|
||||
<div
|
||||
className="pico"
|
||||
style={p.icon
|
||||
? { background: `url(${p.icon}) center/cover no-repeat` }
|
||||
: { background: '#a8b0ba' }}
|
||||
>
|
||||
{p.icon ? '' : (p.name || '?').slice(0, 1)}
|
||||
</div>
|
||||
<div className="pmain">
|
||||
<div className="ptitle">{p.name}</div>
|
||||
<div className="psub">已归档 · {p.taskCount ?? 0} 个任务</div>
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
// 任务类型标签 (项目级自定义, 默认四类)
|
||||
const KIND_COLORS = {
|
||||
需求: '#1677ff',
|
||||
Bug: '#ff3141',
|
||||
bug: '#ff3141',
|
||||
FEATURE: '#a855f7',
|
||||
优化: '#ff8f1f',
|
||||
}
|
||||
|
||||
export const DEFAULT_KINDS = ['需求', 'Bug', 'FEATURE', '优化']
|
||||
|
||||
export const kindColor = (k) => KIND_COLORS[k] || '#8a919c'
|
||||
@@ -0,0 +1,4 @@
|
||||
// 项目头像配色: 项目列表 / 最近项目 / 项目管理 三处共用, 保证观感一致
|
||||
export const PROJECT_COLORS = ['#1677ff', '#00b578', '#ff8f1f', '#a855f7', '#14b8a6', '#f43f9e']
|
||||
|
||||
export const colorOfProject = (p) => PROJECT_COLORS[Math.abs(p.id) % PROJECT_COLORS.length]
|
||||
@@ -0,0 +1,76 @@
|
||||
// 快速添加的标题解析: 时间词前缀 + 30 字分隔符截断 (T-TK-14/16)
|
||||
export const MAX_TITLE = 30
|
||||
|
||||
// 分隔符: 空格 / 中英文标点 / 斜杠等 —— 截断优先落在分隔符上, 不切词
|
||||
const SEPARATOR = /[\s,,。;;、!!??::“”"'’‘()()\[\]【】\-_/\\|]/
|
||||
|
||||
/**
|
||||
* 长文本拆分: ≤30 字原样; 超长时在 30 字内(且不少于 8 字)找最后一个分隔符截断,
|
||||
* 前段为标题, **完整原文**作为描述 (整体进详情, 不丢内容)。
|
||||
*/
|
||||
export function splitTitle(raw) {
|
||||
const text = String(raw || '').trim()
|
||||
if (text.length <= MAX_TITLE) return { title: text, desc: '' }
|
||||
let cut = -1
|
||||
for (let i = MAX_TITLE; i >= 8; i--) {
|
||||
if (SEPARATOR.test(text[i - 1])) { cut = i; break }
|
||||
}
|
||||
if (cut < 0) cut = MAX_TITLE
|
||||
// 截断落在分隔符上: 分隔符留在标题末尾很难看, 去掉尾部连续分隔符/空白
|
||||
// (\p{P} 覆盖中英文全部标点; 之前用 SEPARATOR.source 重组字符类有转义坑)
|
||||
const title = text.slice(0, cut).replace(/[\s\p{P}]+$/u, '').trim()
|
||||
return { title, desc: text }
|
||||
}
|
||||
|
||||
// 时间词 → 日期 (返回 YYYY-MM-DD):
|
||||
// 今天/明天/后天 = 当日 +0/+1/+2
|
||||
// 本周 = 本周日, 下周 = 下周日 (周一定义为一周开始)
|
||||
// 本月 = 本月最后一天, 下月 = 下月最后一天
|
||||
const toISO = (d) => {
|
||||
const p = (n) => String(n).padStart(2, '0')
|
||||
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`
|
||||
}
|
||||
|
||||
export function dateFromWord(word, now = new Date()) {
|
||||
const day = (n) => { const d = new Date(now); d.setDate(d.getDate() + n); return toISO(d) }
|
||||
const endOfWeek = (offsetWeeks) => {
|
||||
const d = new Date(now)
|
||||
const dow = (d.getDay() + 6) % 7 // 周一=0
|
||||
d.setDate(d.getDate() + offsetWeeks * 7 + (6 - dow))
|
||||
return toISO(d)
|
||||
}
|
||||
const endOfMonth = (offsetMonths) => {
|
||||
const d = new Date(now.getFullYear(), now.getMonth() + offsetMonths + 1, 0)
|
||||
return toISO(d)
|
||||
}
|
||||
switch (word) {
|
||||
case '今天': return day(0)
|
||||
case '明天': return day(1)
|
||||
case '后天': return day(2)
|
||||
case '本周': return endOfWeek(0)
|
||||
case '下周': return endOfWeek(1)
|
||||
case '本月': return endOfMonth(0)
|
||||
case '下月': return endOfMonth(1)
|
||||
default: return null
|
||||
}
|
||||
}
|
||||
|
||||
// 匹配最长的时间词前缀 (后面跟分隔符或直接结束)
|
||||
const TIME_WORDS = ['本周', '下周', '本月', '下月', '今天', '明天', '后天']
|
||||
|
||||
/**
|
||||
* 解析时间词前缀: "明天 交周报" → { markDate: '2026-10-26', rest: '交周报' }
|
||||
* 无匹配时 rest 为原文, markDate 为 null。
|
||||
*/
|
||||
export function parseTimePrefix(raw) {
|
||||
const text = String(raw || '').trim()
|
||||
for (const w of TIME_WORDS) {
|
||||
if (text.startsWith(w)) {
|
||||
const after = text.slice(w.length)
|
||||
if (after === '' || SEPARATOR.test(after[0])) {
|
||||
return { markDate: dateFromWord(w), rest: after.trim() }
|
||||
}
|
||||
}
|
||||
}
|
||||
return { markDate: null, rest: text }
|
||||
}
|
||||
+174
-41
@@ -23,6 +23,13 @@
|
||||
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
/* 全局禁用非编辑类文字的选中/长按菜单 (输入框除外);
|
||||
各页面原有的局部规则保留但不再必要 */
|
||||
* { -webkit-user-select: none; user-select: none; -webkit-touch-callout: none; }
|
||||
input, textarea, [contenteditable="true"], [contenteditable=""] {
|
||||
-webkit-user-select: text; user-select: text; -webkit-touch-callout: default;
|
||||
}
|
||||
|
||||
html, body, #root {
|
||||
height: 100%;
|
||||
background: var(--color-bg);
|
||||
@@ -72,6 +79,11 @@ input, textarea, select { font: inherit; }
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
/* 页面容器: 高度打通 app-main → page-with-navbar 的百分比链。
|
||||
没有这行 height:100%, page-with-navbar 的 height:100% 对 auto 高度的父级失效,
|
||||
整页塌成内容高 —— 看板 board-wrap 随之只卡片区那么高, 横滑只在有任务的区域生效 */
|
||||
.page-transition { height: 100%; }
|
||||
|
||||
/* 页面切换动画: pathname 变化时整页淡入 + 轻微上移 (由 MobileShell 控制 class) */
|
||||
.page-transition.active {
|
||||
animation: page-in 0.28s ease;
|
||||
@@ -658,6 +670,77 @@ body.me-safe-top .app-safe-top { height: 0; }
|
||||
.habit-link { font-size: 12px; color: var(--color-primary); cursor: pointer; }
|
||||
.habit-link.danger { color: var(--color-danger); }
|
||||
|
||||
/* 打卡项分组 + 长按拖动排序 (组内) */
|
||||
.habit-view { -webkit-user-select: none; user-select: none; -webkit-touch-callout: none; }
|
||||
.habit-view input, .habit-view textarea { -webkit-user-select: text; user-select: text; }
|
||||
.habit-group-title {
|
||||
display: flex; align-items: center; gap: 7px; padding: 10px 2px 4px;
|
||||
font-size: 12px; color: var(--color-muted); font-weight: 600;
|
||||
}
|
||||
.habit-group-title::before { content: ""; width: 3px; height: 12px; border-radius: 2px; background: var(--color-primary); }
|
||||
.habit-group-title .cnt { color: #b8bcc4; font-weight: 400; }
|
||||
.habit-card { touch-action: pan-y; }
|
||||
.habit-card.dragging { opacity: 0.55; outline: 2px dashed var(--color-primary); outline-offset: 1px; }
|
||||
.habit-card.dragover { outline: 2px solid var(--color-primary); outline-offset: 1px; }
|
||||
.habit-card.dragover.after { border-bottom: 3px solid var(--color-primary); }
|
||||
.habit-card.dragover.before { border-top: 3px solid var(--color-primary); }
|
||||
|
||||
/* 拖动把手 (⠿): touch-action:none —— 浏览器完全不接管该触摸,
|
||||
指针流不会被滚动抢占取消, 是 Android WebView 上唯一可靠的拖动入口 */
|
||||
.grip {
|
||||
touch-action: none; -webkit-user-select: none; user-select: none;
|
||||
color: #c2c7cf; font-size: 10px; line-height: 1;
|
||||
padding: 10px 1px; margin: -6px -2px; min-width: 8px; min-height: 26px;
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
cursor: grab; flex-shrink: 0; align-self: center;
|
||||
}
|
||||
.grip:active { color: var(--color-primary); }
|
||||
.task-right .grip { margin-right: 2px; }
|
||||
|
||||
/* MergedView 打卡卡拖动排序 */
|
||||
.mv-view { -webkit-user-select: none; user-select: none; -webkit-touch-callout: none; }
|
||||
.mv-view input, .mv-view textarea { -webkit-user-select: text; user-select: text; }
|
||||
.mv-card { touch-action: pan-y; }
|
||||
.mv-card.dragging { opacity: .3; outline: 2px dashed var(--color-primary); outline-offset: 1px; }
|
||||
.mv-card.dragover { outline: 2px solid var(--color-primary); outline-offset: 1px; }
|
||||
.mv-card.dragover.after { border-bottom: 3px solid var(--color-primary); }
|
||||
.mv-card.dragover.before { border-top: 3px solid var(--color-primary); }
|
||||
|
||||
/* 任务类型小标签: 列表行左下 / 看板卡左下 */
|
||||
.date-tag {
|
||||
display: inline-flex; align-items: center;
|
||||
font-size: 10px; line-height: 1; font-weight: 600;
|
||||
color: #ad6800; background: #fff7e6; border-radius: 4px; padding: 3px 5px;
|
||||
flex-shrink: 0; white-space: nowrap; font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.kind-tag {
|
||||
display: inline-flex; align-items: center;
|
||||
font-size: 10px; line-height: 1; font-weight: 600; color: #fff;
|
||||
border-radius: 4px; padding: 3px 5px; flex-shrink: 0; white-space: nowrap;
|
||||
}
|
||||
/* 项目管理: 任务类型编辑 chips */
|
||||
.kind-chip-row { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||
.kind-chip {
|
||||
display: inline-flex; align-items: center; gap: 5px;
|
||||
font-size: 12.5px; color: #fff; border-radius: 999px; padding: 5px 6px 5px 11px;
|
||||
}
|
||||
.kind-chip .x { font-size: 13px; color: rgba(255,255,255,.75); cursor: pointer; padding: 0 4px; }
|
||||
|
||||
/* 拖动跟手浮层: 克隆原卡片淡化后挂在 body 顶层, 跟随指针 */
|
||||
.drag-ghost {
|
||||
position: fixed; left: 0; top: 0; z-index: 1000;
|
||||
pointer-events: none; will-change: transform;
|
||||
background: transparent; padding: 0; margin: 0; max-width: none; border-radius: 0;
|
||||
-webkit-user-select: none; user-select: none; -webkit-touch-callout: none;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
.drag-ghost * { -webkit-tap-highlight-color: transparent; }
|
||||
.drag-ghost > * {
|
||||
margin: 0 !important; pointer-events: none;
|
||||
opacity: .82; border-radius: 9px;
|
||||
box-shadow: 0 12px 28px rgba(15,23,42,.28);
|
||||
}
|
||||
|
||||
/* ----- 日历视图 ----- */
|
||||
.calendar-view { padding: 0 12px 20px; }
|
||||
.cal-month-bar { display: flex; align-items: center; gap: 10px; padding: 8px 2px; }
|
||||
@@ -902,8 +985,17 @@ body.me-safe-top .app-safe-top { height: 0; }
|
||||
width: 34px; height: 34px; border-radius: 9px; display: flex; align-items: center;
|
||||
justify-content: center; color: #fff; font-size: 14px; font-weight: 700; flex-shrink: 0;
|
||||
}
|
||||
/* 项目头像基类 (不依赖 .proj-item 作用域): 项目管理编辑页预览等裸用场景 */
|
||||
.pavatar {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
color: #fff; font-weight: 700; flex-shrink: 0;
|
||||
background-size: cover; background-position: center; background-repeat: no-repeat;
|
||||
}
|
||||
.proj-item .pmain { flex: 1; min-width: 0; }
|
||||
.proj-item .ptitle { font-size: 14.5px; font-weight: 600; display: flex; align-items: center; gap: 6px; }
|
||||
.proj-item .ptitle {
|
||||
font-size: 14.5px; font-weight: 600; display: flex; align-items: center; gap: 6px;
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
.proj-item .psub { font-size: 11.5px; color: var(--color-muted); margin-top: 2px; }
|
||||
.proj-item .pcount { font-size: 15px; color: #c0c4cc; flex-shrink: 0; }
|
||||
.badge { font-size: 10px; color: #ad6800; background: #fff7e6; border-radius: 4px; padding: 0 5px; font-weight: 400; }
|
||||
@@ -942,21 +1034,30 @@ body.me-safe-top .app-safe-top { height: 0; }
|
||||
.task-main { flex: 1; min-width: 0; }
|
||||
.task-title { font-size: 14px; display: flex; align-items: center; gap: 6px; word-break: break-word; }
|
||||
.task-row.done .task-title { color: #a0a0a0; text-decoration: line-through; }
|
||||
.task-meta { display: flex; align-items: center; gap: 7px; margin-top: 4px; font-size: 11px; color: #999; flex-wrap: wrap; }
|
||||
.task-meta .col { color: var(--color-primary); background: #f0f5ff; border-radius: 4px; padding: 0 5px; }
|
||||
.task-meta {
|
||||
display: flex; align-items: center; gap: 7px; margin-top: 4px; font-size: 11px;
|
||||
color: #999; flex-wrap: wrap; min-height: 17px;
|
||||
}
|
||||
.task-meta .assignee { font-size: 12px; font-weight: 600; color: var(--color-text); }
|
||||
.task-meta .assignee.none { color: #b8bcc4; font-weight: 400; }
|
||||
.task-meta .members { font-size: 10.5px; color: #a7adb6; }
|
||||
.task-right { display: flex; align-items: center; flex-shrink: 0; }
|
||||
.task-right {
|
||||
display: flex; flex-direction: column; align-items: flex-end; justify-content: space-between;
|
||||
align-self: stretch; flex-shrink: 0;
|
||||
}
|
||||
.task-right .assignee { font-size: 12px; font-weight: 600; color: var(--color-text); }
|
||||
.task-right .assignee.none { color: #b8bcc4; font-weight: 400; }
|
||||
.more { color: #c2c7cf; font-size: 16px; line-height: 1; padding: 0 2px; cursor: pointer; }
|
||||
.sub-row {
|
||||
display: flex; align-items: center; gap: 8px; background: #fff; border-radius: 8px;
|
||||
display: flex; align-items: flex-start; gap: 8px; background: #fff; border-radius: 8px;
|
||||
padding: 8px 12px 8px 30px; margin: -2px 12px 7px 26px; box-shadow: 0 1px 2px rgba(0,0,0,.03);
|
||||
position: relative; cursor: pointer;
|
||||
}
|
||||
.sub-row::before { content: ""; position: absolute; left: 14px; top: 0; bottom: 0; width: 1px; background: #e9ecf0; }
|
||||
.sub-row .box { width: 16px; height: 16px; }
|
||||
.sub-title { flex: 1; font-size: 13px; color: #444; }
|
||||
.sub-row .box { width: 16px; height: 16px; margin-top: 2px; }
|
||||
.sub-main { flex: 1; min-width: 0; }
|
||||
.sub-title { display: block; font-size: 13px; color: #444; }
|
||||
.sub-row .task-meta { font-size: 10.5px; margin-top: 2px; gap: 5px; }
|
||||
.sub-row.done .sub-title { color: #a8a8a8; text-decoration: line-through; }
|
||||
.collapse { font-size: 11px; color: var(--color-primary); padding: 2px 0 8px 30px; margin: 0 12px; cursor: pointer; }
|
||||
.sub-add-hint { font-size: 11px; color: #b8bcc4; padding: 0 0 8px 44px; cursor: pointer; }
|
||||
@@ -976,25 +1077,45 @@ body.me-safe-top .app-safe-top { height: 0; }
|
||||
display: flex; gap: 10px; padding: 0 12px 8px; overflow-x: auto; overflow-y: hidden;
|
||||
flex: 1 1 auto; min-height: 0; align-items: stretch;
|
||||
}
|
||||
.bcol { flex: 0 0 186px; display: flex; flex-direction: column; min-height: 0; }
|
||||
.bcol-head { display: flex; align-items: center; gap: 5px; padding: 0 2px 7px; flex-shrink: 0; }
|
||||
.bcol-name { font-size: 12px; font-weight: 700; color: #3d4450; }
|
||||
.bcol { flex: 0 0 186px; display: flex; flex-direction: column; min-height: 0; }.bcol-head { display: flex; align-items: center; gap: 5px; padding: 0 2px 7px; flex-shrink: 0; }
|
||||
.bcol-name { font-size: 12px; font-weight: 700; color: #3d4450; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.bcol-cnt { font-size: 11px; color: #999; background: #e8ebef; border-radius: 999px; padding: 0 6px; line-height: 15px; }
|
||||
.bcol-bind { font-size: 10px; color: var(--color-primary); background: #f0f5ff; border-radius: 4px; padding: 0 4px; margin-left: auto; }
|
||||
.bcol-bind.off { color: #999; background: #f2f3f5; }
|
||||
.bcol-body { flex: 1 1 auto; min-height: 0; overflow-y: auto; display: flex; flex-direction: column; gap: 7px; padding-bottom: 8px; }
|
||||
.bcol-body {
|
||||
flex: 1 1 auto; min-height: 0; height: 100%; overflow-y: auto;
|
||||
display: flex; flex-direction: column; gap: 7px; padding-bottom: 8px;
|
||||
-webkit-overflow-scrolling: touch; overscroll-behavior: contain;
|
||||
}
|
||||
/* 列体包裹层: 承载右缘滚动条 (滚动时出现, 停止后淡出) */
|
||||
.bcol-main { flex: 1 1 auto; min-height: 0; position: relative; display: flex; }
|
||||
.bcol-bar {
|
||||
position: absolute; right: 1px; width: 3px; border-radius: 2px;
|
||||
background: rgba(15, 23, 42, .3); opacity: 0; transition: opacity .25s ease;
|
||||
pointer-events: none; z-index: 3;
|
||||
}
|
||||
.bcol-bar.on { opacity: 1; }
|
||||
.bcol.dragover .bcol-body { background: #eaf2ff; border-radius: 8px; outline: 1.5px dashed var(--color-primary); }
|
||||
.bcol-empty { font-size: 11px; color: #c0c4cc; text-align: center; padding: 10px 0; }
|
||||
.bcard {
|
||||
background: #fff; border-radius: 8px; padding: 9px 10px;
|
||||
box-shadow: 0 1px 2px rgba(0,0,0,.05); cursor: pointer; flex-shrink: 0;
|
||||
display: flex; gap: 7px; align-items: flex-start;
|
||||
}
|
||||
.bcard.dragging { opacity: .55; transform: scale(.97) rotate(-1deg); box-shadow: 0 8px 18px rgba(15,23,42,.16); }
|
||||
/* 左侧竖排操作列: 状态点在上, ⋯ 沉到左下角; 负外距悬进卡片内边距, 少占横向空间 */
|
||||
.bcard-left {
|
||||
display: flex; flex-direction: column; align-items: center; flex-shrink: 0;
|
||||
align-self: stretch; justify-content: space-between; padding: 1px 0 2px;
|
||||
margin: 0 -5px; width: 14px;
|
||||
}
|
||||
.bcard-left .bstat { padding: 0; }
|
||||
.bcard-left .bmore-btn { font-size: 12px; padding: 0; line-height: 1.1; }
|
||||
.bcard-body { flex: 1; min-width: 0; }
|
||||
.bcard.hl { outline: 2px solid var(--color-primary); outline-offset: 1px; }
|
||||
.brow1 { display: flex; align-items: flex-start; gap: 6px; }
|
||||
.btitle { flex: 1; font-size: 12.5px; font-weight: 600; line-height: 1.45; word-break: break-word; }
|
||||
.bstat { display: flex; align-items: center; padding: 2px; cursor: pointer; }
|
||||
.bmeta { display: flex; align-items: center; gap: 6px; margin-top: 6px; font-size: 10.5px; }
|
||||
.bmeta { display: flex; align-items: center; justify-content: flex-end; gap: 6px; margin-top: 6px; font-size: 10.5px; }
|
||||
.bassignee { font-size: 11px; font-weight: 600; color: #4a5160; }
|
||||
.bassignee.none { color: #b8bcc4; font-weight: 400; }
|
||||
.bmembers { font-size: 10px; color: #a7adb6; }
|
||||
@@ -1012,18 +1133,16 @@ body.me-safe-top .app-safe-top { height: 0; }
|
||||
background: rgba(255,255,255,.6); border: 1px dashed #d5dae1; border-radius: 8px;
|
||||
padding: 8px; text-align: center; font-size: 11.5px; color: #b8bcc4; cursor: pointer; flex-shrink: 0;
|
||||
}
|
||||
.drag-ghost {
|
||||
position: fixed; z-index: 999; transform: translate(-50%, -50%); pointer-events: none;
|
||||
background: rgba(22,119,255,.92); color: #fff; font-size: 12px; padding: 6px 10px;
|
||||
border-radius: 8px; max-width: 180px; box-shadow: 0 8px 18px rgba(15,23,42,.25);
|
||||
}
|
||||
|
||||
/* ---- 列设置 / 成员页 ---- */
|
||||
/* fixed 覆盖层要自己让出顶部状态栏 (复用 main.jsx 校准的 --safe-top), 否则与系统工具栏重合 */
|
||||
.cs-page { position: fixed; inset: 0; z-index: 1000; background: var(--color-bg); display: flex; flex-direction: column; padding-top: var(--safe-top); }
|
||||
.cs-page .navbar-sub { display: flex; align-items: center; gap: 10px; padding: 12px 14px; background: #fff; border-bottom: 1px solid var(--color-border); flex-shrink: 0; }
|
||||
.cs-page .navbar-sub .back { font-size: 20px; color: #666; line-height: 1; cursor: pointer; }
|
||||
.cs-page .navbar-sub .title { font-size: 16px; font-weight: 700; }
|
||||
.cs-page .navbar-sub .title {
|
||||
font-size: 16px; font-weight: 700;
|
||||
min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
.cs-page .navbar-sub .right { margin-left: auto; display: flex; gap: 6px; }
|
||||
.cs-body { flex: 1 1 auto; min-height: 0; overflow-y: auto; padding-bottom: 20px; }
|
||||
.set-row {
|
||||
@@ -1045,8 +1164,14 @@ body.me-safe-top .app-safe-top { height: 0; }
|
||||
.cs-count { font-size: 11px; color: #999; margin-left: 8px; }
|
||||
|
||||
/* ---- 任务详情 ---- */
|
||||
.sheet-title { font-size: 16px; font-weight: 700; margin-bottom: 4px; word-break: break-word; }
|
||||
.sheet-sub { font-size: 11.5px; color: #999; margin-bottom: 14px; }
|
||||
.sheet-title {
|
||||
font-size: 16px; font-weight: 700; margin-bottom: 4px; word-break: break-word;
|
||||
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
|
||||
}
|
||||
.sheet-sub {
|
||||
font-size: 11.5px; color: #999; margin-bottom: 14px;
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
.field { margin-bottom: 13px; }
|
||||
.field label { display: block; font-size: 12px; color: var(--color-muted); margin-bottom: 6px; }
|
||||
.inp {
|
||||
@@ -1134,7 +1259,7 @@ textarea.inp { resize: vertical; }
|
||||
.pv-embed .task-content { flex: 1 1 auto; }
|
||||
|
||||
/* ---- 项目列表拖动排序 (bug#3) ---- */
|
||||
.proj-item.dragging { opacity: .65; transform: scale(.98); box-shadow: 0 8px 18px rgba(15,23,42,.16); z-index: 5; position: relative; }
|
||||
.proj-item.dragging { opacity: .65; transform: scale(.98); box-shadow: 0 8px 18px rgba(15,23,42,.16); z-index: 5; position: relative; }
|
||||
|
||||
/* ---- 项目信息块 (T-PJ-13) ---- */
|
||||
.note-row { cursor: pointer; }
|
||||
@@ -1142,7 +1267,7 @@ textarea.inp { resize: vertical; }
|
||||
.note-preview {
|
||||
font-size: 11.5px; color: #999; margin-top: 3px; overflow: hidden;
|
||||
text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- 通用分段控件 (状态选择等): 当前项高亮 ----
|
||||
此前只有 .pv-head .seg 有定义, 详情弹层里裸用 .seg 会堆成三行 */
|
||||
@@ -1151,25 +1276,33 @@ textarea.inp { resize: vertical; }
|
||||
.seg div.on { background: #fff; color: var(--color-text); font-weight: 600; box-shadow: 0 1px 2px rgba(0,0,0,.06); }
|
||||
|
||||
/* ---- 看板卡片 ⋯ 菜单入口 ---- */
|
||||
.bmore-btn { margin-left: auto; color: #c2c7cf; font-size: 14px; line-height: 1; padding: 2px 4px; cursor: pointer; }
|
||||
.bmore-btn { color: #c2c7cf; font-size: 14px; line-height: 1; padding: 2px 4px; cursor: pointer; }
|
||||
.bcard-left .bmore-btn { padding: 0 4px; }
|
||||
|
||||
/* ---- ActionSheet 标题 (任务 ⋯ 菜单) ---- */
|
||||
.as-extra { padding: 4px 0 8px; }
|
||||
.as-title { font-size: 13px; font-weight: 600; color: var(--color-text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.as-title { font-size: 13px; font-weight: 600; color: var(--color-text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
/* ---- 键盘避让弹层 (重点设计 §7.2) ----
|
||||
底部弹层统一挂 .kb-safe: 底部留白 = 安全区 + 20px + 键盘插入量(--kb-inset,
|
||||
由 App 级 useKeyboardInsetWatcher 写入)。键盘弹出时内容抬到键盘上方,
|
||||
弹层自身 max-height 用 dvh 随视口收缩并内部滚动。 */
|
||||
.kb-safe {
|
||||
border-top-left-radius: 12px;
|
||||
border-top-right-radius: 12px;
|
||||
max-height: min(86dvh, calc(100dvh - 120px));
|
||||
overflow-y: auto;
|
||||
padding: 16px 16px calc(env(safe-area-inset-bottom, 0px) + 20px + var(--kb-inset, 0px));
|
||||
}
|
||||
|
||||
/* ---- 拖动方案 (重点设计 §7.1) ----
|
||||
卡片允许纵向滚动(PAN-Y), 水平位移留给拖动; 激活后原位抬起 */
|
||||
.bcard { touch-action: pan-y; }
|
||||
.bcard.dragging { opacity: .5; transform: scale(.97); box-shadow: 0 10px 22px rgba(15,23,42,.22); z-index: 6; }
|
||||
/* ---- 拖动手势 (重点设计 §7.1) ----
|
||||
看板卡片 / 列表行: 纵向滚动归浏览器(pan-y), 水平位移留给拖动换列。
|
||||
任务区整体禁用长按文字选择与系统菜单, 避免长按拖动变成选中文字 (输入框除外)。 */
|
||||
.task-content {
|
||||
-webkit-user-select: none; user-select: none;
|
||||
-webkit-touch-callout: none;
|
||||
}
|
||||
.task-content input, .task-content textarea { -webkit-user-select: text; user-select: text; }
|
||||
.bcard, .task-row { touch-action: pan-y; }
|
||||
.bcard.dragging {
|
||||
opacity: .35; outline: 2px dashed var(--color-primary); outline-offset: 1px;
|
||||
}
|
||||
.task-row.dragging {
|
||||
opacity: .35; outline: 2px dashed var(--color-primary); outline-offset: 1px;
|
||||
}
|
||||
.task-row.dragover { outline: 2px solid var(--color-primary); outline-offset: 1px; }
|
||||
.task-row.dragover.after { border-bottom: 3px solid var(--color-primary); }
|
||||
.task-row.dragover.before { border-top: 3px solid var(--color-primary); }
|
||||
.drag-pill {
|
||||
font-size: 11px; font-weight: 600; color: #fff; background: var(--color-primary);
|
||||
border-radius: 999px; padding: 2px 9px; flex-shrink: 0; white-space: nowrap;
|
||||
}
|
||||
.drag-pill.cur { background: #c0c7d0; }
|
||||
|
||||
Reference in New Issue
Block a user