Compare commits
31
Commits
9fea1a5a3a
...
1cd5b85a4b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1cd5b85a4b | ||
|
|
8203a7f893 | ||
|
|
0f71323c11 | ||
|
|
93b7c7bb0a | ||
|
|
67be8d8f7b | ||
|
|
06aa48f6ad | ||
|
|
6bd39aeed2 | ||
|
|
fd66c529c2 | ||
|
|
cee21a39d9 | ||
|
|
e3ac00a641 | ||
|
|
74e1e7dd13 | ||
|
|
fd27242e4e | ||
|
|
20cc1ccde9 | ||
|
|
9401bd2b3d | ||
|
|
fb9e2350b3 | ||
|
|
2e6cc986a0 | ||
|
|
d20837f9f1 | ||
|
|
c0b55277a4 | ||
|
|
49037371f8 | ||
|
|
2fa41c774b | ||
|
|
1673cbdb7d | ||
|
|
5066007d9d | ||
|
|
9a8313173d | ||
|
|
83cc218c5e | ||
|
|
215263bf2f | ||
|
|
2725f00f68 | ||
|
|
b1c1b16e66 | ||
|
|
51256335ba | ||
|
|
20a5105c23 | ||
|
|
9467d4c242 | ||
|
|
d6ca7573f3 |
@@ -0,0 +1,11 @@
|
|||||||
|
# 大目录/与镜像无关的内容不进 build context
|
||||||
|
# 注意: dist/ 和 ota-package/ 不能忽略 —— CI 预构建产物要通过 context 进入镜像
|
||||||
|
.git
|
||||||
|
.gitea
|
||||||
|
.local
|
||||||
|
.pnpm-store
|
||||||
|
node_modules
|
||||||
|
src-tauri
|
||||||
|
docs
|
||||||
|
*.log
|
||||||
|
readme
|
||||||
@@ -7,6 +7,11 @@ on:
|
|||||||
branches:
|
branches:
|
||||||
- 'android-*'
|
- 'android-*'
|
||||||
|
|
||||||
|
# 同分支连续 push 时取消旧的构建, 省资源也避免上传过时 APK
|
||||||
|
concurrency:
|
||||||
|
group: android-${{ github.ref_name }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-android:
|
build-android:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -55,7 +60,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
~/.cargo
|
~/.cargo
|
||||||
~/.rustup
|
~/.rustup/toolchains
|
||||||
# 工具链升级时 bump 版本号使缓存失效重装 (含 cargo-ndk)
|
# 工具链升级时 bump 版本号使缓存失效重装 (含 cargo-ndk)
|
||||||
key: rust-android-v1-${{ runner.os }}
|
key: rust-android-v1-${{ runner.os }}
|
||||||
|
|
||||||
@@ -63,7 +68,9 @@ jobs:
|
|||||||
uses: https://gitea.com/actions/cache@v4
|
uses: https://gitea.com/actions/cache@v4
|
||||||
with:
|
with:
|
||||||
path: src-tauri/target
|
path: src-tauri/target
|
||||||
key: tauri-target-android-${{ runner.os }}
|
# 依赖变化 → 新 key; 命不中时回退最近一次的 target 目录做增量编译
|
||||||
|
key: tauri-target-android-${{ hashFiles('src-tauri/Cargo.lock') }}
|
||||||
|
restore-keys: tauri-target-android-
|
||||||
|
|
||||||
# ---------- 安装工具链 (仅缓存未命中时) ----------
|
# ---------- 安装工具链 (仅缓存未命中时) ----------
|
||||||
- name: Install Rust + Android targets
|
- name: Install Rust + Android targets
|
||||||
@@ -72,7 +79,8 @@ jobs:
|
|||||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable
|
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable
|
||||||
echo "$HOME/.cargo/bin" >> $GITHUB_PATH
|
echo "$HOME/.cargo/bin" >> $GITHUB_PATH
|
||||||
. "$HOME/.cargo/env"
|
. "$HOME/.cargo/env"
|
||||||
rustup target add aarch64-linux-android armv7-linux-androideabi x86_64-linux-android i686-linux-android
|
# 只构建 aarch64 (现代 Android 手机); 需要模拟器/x86 设备时再加回其他 target
|
||||||
|
rustup target add aarch64-linux-android
|
||||||
cargo install cargo-ndk --locked
|
cargo install cargo-ndk --locked
|
||||||
|
|
||||||
- name: Install Android SDK & NDK
|
- name: Install Android SDK & NDK
|
||||||
@@ -130,7 +138,7 @@ jobs:
|
|||||||
export CARGO_HOME="$HOME/.cargo"
|
export CARGO_HOME="$HOME/.cargo"
|
||||||
. "$HOME/.cargo/env"
|
. "$HOME/.cargo/env"
|
||||||
cd src-tauri
|
cd src-tauri
|
||||||
npx tauri android build --apk
|
npx tauri android build --apk --target aarch64
|
||||||
# 断言签名配置仍在且 release 块实际引用了它 (防止 tauri 重新生成 gen/android 或正则未命中)
|
# 断言签名配置仍在且 release 块实际引用了它 (防止 tauri 重新生成 gen/android 或正则未命中)
|
||||||
if ! grep -q 'create("release")' gen/android/app/build.gradle.kts || ! grep -q 'signingConfig = signingConfigs.getByName("release")' gen/android/app/build.gradle.kts; then
|
if ! grep -q 'create("release")' gen/android/app/build.gradle.kts || ! grep -q 'signingConfig = signingConfigs.getByName("release")' gen/android/app/build.gradle.kts; then
|
||||||
echo "ERROR: 构建后签名配置丢失/未生效, APK 未签名" >&2
|
echo "ERROR: 构建后签名配置丢失/未生效, APK 未签名" >&2
|
||||||
|
|||||||
@@ -18,6 +18,11 @@ on:
|
|||||||
branches:
|
branches:
|
||||||
- master
|
- master
|
||||||
|
|
||||||
|
# 连续 push master 时取消还在跑的旧部署, 避免排队执行过时版本的完整部署
|
||||||
|
concurrency:
|
||||||
|
group: deploy-master
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-image:
|
build-image:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -38,6 +43,7 @@ jobs:
|
|||||||
uses: https://gitea.com/actions/setup-node@v4
|
uses: https://gitea.com/actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
|
cache: 'npm'
|
||||||
|
|
||||||
- name: Check version consistency & branch
|
- name: Check version consistency & branch
|
||||||
# 1) package.json / tauri.conf.json / Cargo.toml / Cargo.lock 四处版本必须完全相等且不带 -ota
|
# 1) package.json / tauri.conf.json / Cargo.toml / Cargo.lock 四处版本必须完全相等且不带 -ota
|
||||||
@@ -53,21 +59,43 @@ jobs:
|
|||||||
run: npm ci --no-audit --no-fund
|
run: npm ci --no-audit --no-fund
|
||||||
|
|
||||||
- name: Build frontend
|
- name: Build frontend
|
||||||
# dist/ 供 OTA 打包用 (Dockerfile 内也会构建一份)
|
# 全流程唯一一次前端构建: dist/ 既供 OTA 打包, 也通过 build context 进入镜像
|
||||||
|
# (docker build 传 PREBUILT_DIST=true 跳过镜像内的重复构建)
|
||||||
run: npm run build
|
run: npm run build
|
||||||
|
|
||||||
- name: Install minisign
|
- name: Install minisign
|
||||||
run: sudo apt-get update && sudo apt-get install -y minisign
|
# 静态二进制直接下载, 免去每次 apt-get update + install
|
||||||
|
# 注意: release tarball 是 macOS 上打包的, 混有多个平台的 minisign 二进制,
|
||||||
|
# 逐个试运行挑出本机可执行的, 都不行再回退 apt
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
mkdir -p /tmp/ms
|
||||||
|
curl -sL https://github.com/jedisct1/minisign/releases/download/0.12/minisign-0.12-linux.tar.gz | tar xz -C /tmp/ms
|
||||||
|
BIN=""
|
||||||
|
for c in $(find /tmp/ms -type f -name minisign); do
|
||||||
|
if "$c" -v >/dev/null 2>&1; then BIN="$c"; break; fi
|
||||||
|
done
|
||||||
|
if [ -n "$BIN" ]; then
|
||||||
|
sudo install -m755 "$BIN" /usr/local/bin/minisign
|
||||||
|
else
|
||||||
|
echo "未找到可执行的静态二进制, 回退 apt 安装"
|
||||||
|
sudo apt-get update && sudo apt-get install -y minisign
|
||||||
|
fi
|
||||||
|
minisign -v
|
||||||
|
|
||||||
- name: Build & sign OTA package
|
- name: Build & sign OTA package
|
||||||
# 每次主分支提交生成 OTA 升级包, sequence 默认时间戳 (单调递增)
|
# 每次主分支提交生成 OTA 升级包, sequence 默认时间戳 (单调递增)
|
||||||
|
# 发布说明 = 触发构建的 commit message (标题+正文; 打包脚本内安全截断 400 字符),
|
||||||
|
# commit sha 通过 --commit 单独写入 manifest, 前端分行展示
|
||||||
# 产物 ota-package/ (tar.gz + latest.json + .minisig) 进入 docker build context, 由 Dockerfile COPY 进镜像
|
# 产物 ota-package/ (tar.gz + latest.json + .minisig) 进入 docker build context, 由 Dockerfile COPY 进镜像
|
||||||
env:
|
env:
|
||||||
MINISIGN_PRIVATE_KEY: ${{ secrets.MINISIGN_PRIVATE_KEY }}
|
MINISIGN_PRIVATE_KEY: ${{ secrets.MINISIGN_PRIVATE_KEY }}
|
||||||
OTA_NOTES: ${{ github.sha }}
|
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
node scripts/build-ota.mjs --out ota-package --notes ${OTA_NOTES} --min-binary ${OTA_MIN_BINARY}
|
# 不用 head -c 截断: 那会把多字节汉字切成非法 UTF-8, latest.json 解析失败
|
||||||
|
MSG="$(git log -1 --pretty=format:%B | sed -e '/^[[:space:]]*$/d')"
|
||||||
|
echo "notes: ${MSG}"
|
||||||
|
node scripts/build-ota.mjs --out ota-package --notes "${MSG}" --commit "${GITHUB_SHA}" --min-binary ${OTA_MIN_BINARY}
|
||||||
echo "===== latest.json ====="
|
echo "===== latest.json ====="
|
||||||
cat ota-package/latest.json
|
cat ota-package/latest.json
|
||||||
echo "===== bundle sha256 ====="
|
echo "===== bundle sha256 ====="
|
||||||
@@ -75,7 +103,11 @@ jobs:
|
|||||||
|
|
||||||
- name: Build Docker image
|
- name: Build Docker image
|
||||||
# DATABASE_URL 仅在 prisma generate 阶段需要, 可用任何合法占位串
|
# DATABASE_URL 仅在 prisma generate 阶段需要, 可用任何合法占位串
|
||||||
run: docker build -t iboard:latest --build-arg DATABASE_URL=mysql://build:build@127.0.0.1:3306/build .
|
# PREBUILT_DIST=true: 复用上一步构建好的 dist/, 镜像内跳过前端构建
|
||||||
|
# DOCKER_BUILDKIT=1: Dockerfile 用了 cache mount 语法
|
||||||
|
run: docker build -t iboard:latest --build-arg DATABASE_URL=mysql://build:build@127.0.0.1:3306/build --build-arg PREBUILT_DIST=true .
|
||||||
|
env:
|
||||||
|
DOCKER_BUILDKIT: '1'
|
||||||
|
|
||||||
- name: List images
|
- name: List images
|
||||||
run: docker images
|
run: docker images
|
||||||
@@ -95,8 +127,11 @@ jobs:
|
|||||||
- name: Remove old container
|
- name: Remove old container
|
||||||
run: docker rm -vf iboard || true
|
run: docker rm -vf iboard || true
|
||||||
|
|
||||||
- name: Recycle container
|
- name: Prune dangling images
|
||||||
run: docker system prune --volumes -f
|
# 只清理本项目的悬空镜像 (旧 iboard:latest 被同名新镜像顶掉后变 dangling),
|
||||||
|
# 不再用 `docker system prune --volumes` —— 那会清光宿主机 build cache,
|
||||||
|
# 导致每次部署都全量重装 npm 依赖、重跑构建
|
||||||
|
run: docker image prune -f
|
||||||
|
|
||||||
- name: Run container
|
- name: Run container
|
||||||
# DATABASE_URL 通过 gitea Actions secret 注入, 不进镜像层
|
# DATABASE_URL 通过 gitea Actions secret 注入, 不进镜像层
|
||||||
|
|||||||
@@ -46,21 +46,43 @@ jobs:
|
|||||||
run: npm run build
|
run: npm run build
|
||||||
|
|
||||||
- name: Install minisign
|
- name: Install minisign
|
||||||
run: sudo apt-get update && sudo apt-get install -y minisign
|
# 静态二进制直接下载, 免去每次 apt-get update + install
|
||||||
|
# 注意: release tarball 是 macOS 上打包的, 混有多个平台的 minisign 二进制,
|
||||||
|
# 逐个试运行挑出本机可执行的, 都不行再回退 apt
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
mkdir -p /tmp/ms
|
||||||
|
curl -sL https://github.com/jedisct1/minisign/releases/download/0.12/minisign-0.12-linux.tar.gz | tar xz -C /tmp/ms
|
||||||
|
BIN=""
|
||||||
|
for c in $(find /tmp/ms -type f -name minisign); do
|
||||||
|
if "$c" -v >/dev/null 2>&1; then BIN="$c"; break; fi
|
||||||
|
done
|
||||||
|
if [ -n "$BIN" ]; then
|
||||||
|
sudo install -m755 "$BIN" /usr/local/bin/minisign
|
||||||
|
else
|
||||||
|
echo "未找到可执行的静态二进制, 回退 apt 安装"
|
||||||
|
sudo apt-get update && sudo apt-get install -y minisign
|
||||||
|
fi
|
||||||
|
minisign -v
|
||||||
|
|
||||||
- name: Build & sign OTA package
|
- name: Build & sign OTA package
|
||||||
|
# 发布说明默认 = 检出 commit 的 message (标题+正文), commit sha 单独写入;
|
||||||
|
# 手动填写的 notes 优先
|
||||||
env:
|
env:
|
||||||
MINISIGN_PRIVATE_KEY: ${{ secrets.MINISIGN_PRIVATE_KEY }}
|
MINISIGN_PRIVATE_KEY: ${{ secrets.MINISIGN_PRIVATE_KEY }}
|
||||||
OTA_NOTES: ${{ inputs.notes }}
|
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
OUT="ota-package"
|
OUT="ota-package"
|
||||||
ARGS="--out ${OUT}"
|
ARGS="--out ${OUT} --commit ${GITHUB_SHA}"
|
||||||
# sequence 留空 → 脚本默认用时间戳; min_binary 默认 0.1.0 (与前端版本解耦)
|
|
||||||
if [ -n "${{ inputs.sequence }}" ]; then ARGS="${ARGS} --sequence ${{ inputs.sequence }}"; fi
|
if [ -n "${{ inputs.sequence }}" ]; then ARGS="${ARGS} --sequence ${{ inputs.sequence }}"; fi
|
||||||
if [ -n "${OTA_NOTES}" ]; then ARGS="${ARGS} --notes ${OTA_NOTES}"; fi
|
if [ -n "${{ inputs.notes }}" ]; then
|
||||||
|
ARGS="${ARGS} --notes \"${{ inputs.notes }}\""
|
||||||
|
else
|
||||||
|
MSG="$(git log -1 --pretty=format:%B | sed -e '/^[[:space:]]*$/d')"
|
||||||
|
ARGS="${ARGS} --notes \"${MSG}\""
|
||||||
|
fi
|
||||||
if [ -n "${{ inputs.min_binary }}" ]; then ARGS="${ARGS} --min-binary ${{ inputs.min_binary }}"; fi
|
if [ -n "${{ inputs.min_binary }}" ]; then ARGS="${ARGS} --min-binary ${{ inputs.min_binary }}"; fi
|
||||||
node scripts/build-ota.mjs ${ARGS}
|
eval "node scripts/build-ota.mjs ${ARGS}"
|
||||||
echo "===== latest.json ====="
|
echo "===== latest.json ====="
|
||||||
cat ${OUT}/latest.json
|
cat ${OUT}/latest.json
|
||||||
echo "===== bundle sha256 ====="
|
echo "===== bundle sha256 ====="
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# CHANGELOG
|
||||||
|
|
||||||
|
版本号规则见 [README.md](README.md)「版本管理」。前端与外壳版本号**互相独立**、独立发版,
|
||||||
|
分三部分记录;PATCH 仅在明确提出时自增, 否则沿用, 每次变更在对应部分追加一行。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 程序版本 (Web / OTA)
|
||||||
|
|
||||||
|
**当前值: 0.2.0** (管理点: `package.json` 的 `version`; OTA 包 = 同值 + `-ota`)
|
||||||
|
|
||||||
|
| 日期 | 版本 | commit | 要点 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 2026-10-25 | 0.2.0 | — | V0.2 基线。前端与外壳版本解绑; build 标识统一 `YYYYMMDDHHmmss` (北京时间); PATCH 规则确立 (仅明确提出时自增) |
|
||||||
|
| 2026-10-25 | 0.2.1 | 93b7c7b | 主要解决离线编辑与不同终端数据合并问题 |
|
||||||
|
|
||||||
|
## Android 外壳版本
|
||||||
|
|
||||||
|
**当前值: 0.2.0** (管理点: `src-tauri/tauri.conf.json` 的 `version`, 须与 Cargo.toml/Cargo.lock 组内一致; 产物 = APK versionName)
|
||||||
|
|
||||||
|
| 日期 | 版本 | commit | 要点 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 2026-10-25 | 0.2.0 | — | V0.2 基线。外壳与前端版本解绑, 独立维护 (android-* 分支出包) |
|
||||||
|
|
||||||
|
## Windows 外壳版本
|
||||||
|
|
||||||
|
**当前值: —** (尚未建立; 建立后管理点同为 `src-tauri/tauri.conf.json` + Cargo 三件套, 走独立分支/打包工作流)
|
||||||
|
|
||||||
|
| 日期 | 版本 | commit | 要点 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| — | — | — | 暂无 |
|
||||||
+18
-6
@@ -1,8 +1,11 @@
|
|||||||
|
# syntax=docker/dockerfile:1
|
||||||
# ===============================================================
|
# ===============================================================
|
||||||
# iBoard 一体化镜像: 静态前端 (dist/) + Express API (server/) 同进程
|
# iBoard 一体化镜像: 静态前端 (dist/) + Express API (server/) 同进程
|
||||||
# 构建: docker build --build-arg DATABASE_URL=... -t iboard:latest .
|
# 构建: docker build --build-arg DATABASE_URL=... -t iboard:latest .
|
||||||
# 可选 --build-arg VITE_API_BASE=https://api.example.com 让前端走绝对地址
|
# 可选 --build-arg VITE_API_BASE=https://api.example.com 让前端走绝对地址
|
||||||
|
# CI 传入 --build-arg PREBUILT_DIST=true 复用 CI 已构建的 dist/ (跳过镜像内前端构建)
|
||||||
# 运行: docker run -d --name iboard -p 3001:3001 -e DATABASE_URL=... iboard:latest
|
# 运行: docker run -d --name iboard -p 3001:3001 -e DATABASE_URL=... iboard:latest
|
||||||
|
# 需要 BuildKit (cache mount 语法; Docker 23+ 默认开启)
|
||||||
# ===============================================================
|
# ===============================================================
|
||||||
FROM node:22-alpine AS builder
|
FROM node:22-alpine AS builder
|
||||||
|
|
||||||
@@ -17,18 +20,27 @@ COPY prisma ./prisma/
|
|||||||
COPY prisma.config.ts ./
|
COPY prisma.config.ts ./
|
||||||
COPY generated ./generated/
|
COPY generated ./generated/
|
||||||
|
|
||||||
# 装全量依赖 (dev 也装, 后面 build 要用)
|
# 装全量依赖 (dev 也装, 后面 prisma generate / 本地构建前端要用)
|
||||||
RUN npm install
|
# cache mount 复用 npm 下载缓存, 不进镜像层
|
||||||
|
RUN --mount=type=cache,target=/root/.npm npm ci
|
||||||
|
|
||||||
# 拷贝源码
|
# 拷贝源码
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# 构建前端 (输出到 dist/) 与 Prisma 客户端
|
# 构建 Prisma 客户端
|
||||||
RUN npx prisma generate
|
RUN --mount=type=cache,target=/root/.npm npx prisma generate
|
||||||
|
|
||||||
|
# 构建前端 (输出到 dist/)
|
||||||
|
# PREBUILT_DIST=true: CI 已在镜像外构建过 dist/ (供 OTA 打包), 直接复用, 跳过镜像内构建
|
||||||
|
ARG PREBUILT_DIST=false
|
||||||
# build 时把 VITE_API_BASE 写进 bundle, 默认空 = 走相对路径 /api (同源 Express)
|
# build 时把 VITE_API_BASE 写进 bundle, 默认空 = 走相对路径 /api (同源 Express)
|
||||||
ARG VITE_API_BASE=
|
ARG VITE_API_BASE=
|
||||||
ENV VITE_API_BASE=$VITE_API_BASE
|
ENV VITE_API_BASE=$VITE_API_BASE
|
||||||
RUN npm run build
|
RUN if [ "$PREBUILT_DIST" = "true" ]; then \
|
||||||
|
test -f dist/index.html || { echo "PREBUILT_DIST=true 但 dist/ 缺失"; exit 1; }; \
|
||||||
|
else \
|
||||||
|
npm run build; \
|
||||||
|
fi
|
||||||
|
|
||||||
# 运行时阶段
|
# 运行时阶段
|
||||||
FROM node:22-alpine
|
FROM node:22-alpine
|
||||||
@@ -40,7 +52,7 @@ RUN npm config set registry https://registry.npmmirror.com
|
|||||||
|
|
||||||
# 只装运行时依赖
|
# 只装运行时依赖
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
RUN npm install --omit=dev
|
RUN --mount=type=cache,target=/root/.npm npm ci --omit=dev
|
||||||
|
|
||||||
# 拷贝构建产物
|
# 拷贝构建产物
|
||||||
COPY --from=builder /app/dist ./dist
|
COPY --from=builder /app/dist ./dist
|
||||||
|
|||||||
+101
-88
@@ -1,5 +1,28 @@
|
|||||||
# iBoard
|
# iBoard
|
||||||
用于收集和整理经济数据,但是以策略为主体重新组织。
|
|
||||||
|
跨平台个人看板 (Tauri + React + Express + MariaDB)。用于收集和整理经济数据,但是以策略为主体重新组织。
|
||||||
|
|
||||||
|
- 首页主要是放经济文章
|
||||||
|
- 数据主要是放各种经济数据和图表
|
||||||
|
- 策略是放按照经济理论组织的相关数据和图表的对比
|
||||||
|
|
||||||
|
## 首页
|
||||||
|
|
||||||
|
首页显示新闻模块和报警模块的数据,按照时间最新优先,无限滚动,直到数据库中没有数据为止。
|
||||||
|
|
||||||
|
### 新闻模块
|
||||||
|
|
||||||
|
增加一个新闻模块,数据来源是每日夜间 3 点,通过妙想 MCP 获取国内外最新经济政治新闻,按影响大小排序 10 条。存入数据库。
|
||||||
|
|
||||||
|
### 报警模块
|
||||||
|
|
||||||
|
World Bank 中的数据只用来对比妙想 MCP 拉取的数据正确性。当同一时间的同一项数据不同时,记录一个报警。
|
||||||
|
|
||||||
|
## 监控
|
||||||
|
|
||||||
|
监控分为服务器监控和服务监控两部分。服务器监控对接 komari,地址:<https://komari.honor3.com/>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 分支规则
|
## 分支规则
|
||||||
|
|
||||||
@@ -10,7 +33,7 @@
|
|||||||
| `master` | **主分支**(生产分支) | ✅ 唯一部署出口,push 即发布生产 | — |
|
| `master` | **主分支**(生产分支) | ✅ 唯一部署出口,push 即发布生产 | — |
|
||||||
| `dev-*` | **开发集成分支**,如 `dev-0.2` | ❌ 不部署(只跑 CI 校验) | 合到 `master` 后由 master 部署 |
|
| `dev-*` | **开发集成分支**,如 `dev-0.2` | ❌ 不部署(只跑 CI 校验) | 合到 `master` 后由 master 部署 |
|
||||||
| `tmp-*` | **临时分支**,针对单个问题的修改 | ❌ 不部署 | 合到对应 `dev-*` 后删除 |
|
| `tmp-*` | **临时分支**,针对单个问题的修改 | ❌ 不部署 | 合到对应 `dev-*` 后删除 |
|
||||||
| `android-*` | **Android 外壳构建分支**,如 `android-1.0` | ❌ 不部署,只触发 APK 构建 | 构建前须与 `master` 同步 |
|
| `android-*` | **Android 外壳构建分支** | ❌ 不部署,只触发 APK 构建 | 构建前须与 `master` 同步 |
|
||||||
|
|
||||||
> **关于 `main`**:团队约定里的「main 主分支」在本仓库落地为 **`master`**(仓库默认分支即 `master`,不另建 `main`)。
|
> **关于 `main`**:团队约定里的「main 主分支」在本仓库落地为 **`master`**(仓库默认分支即 `master`,不另建 `main`)。
|
||||||
> 下文所有「main 主分支」均指 `master`。
|
> 下文所有「main 主分支」均指 `master`。
|
||||||
@@ -45,35 +68,48 @@ android-1.0 ── 从 master 同步代码后 push ──▶ 只触发 APK 构
|
|||||||
> 此前 `dev-*` 与 `master` 共用同一套生产环境,推送开发分支等于直接发布,容易把半成品带上线。
|
> 此前 `dev-*` 与 `master` 共用同一套生产环境,推送开发分支等于直接发布,容易把半成品带上线。
|
||||||
> 现在开发分支的提交只有合并进 `master` 并推送后才会部署。
|
> 现在开发分支的提交只有合并进 `master` 并推送后才会部署。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 版本管理
|
## 版本管理
|
||||||
|
|
||||||
### 一句话规则
|
### 版本号构成与口径(2026-10-25 决议)
|
||||||
|
|
||||||
**同一个版本号,APK(原生包)裸写,OTA 热更包多一个 `-ota` 后缀。**
|
|
||||||
|
|
||||||
```
|
```
|
||||||
分支 dev-0.2 → 版本 0.2.0
|
V MAJOR.MINOR.PATCH 例: V0.2.0
|
||||||
├─ APK / 桌面安装包 versionName = 0.2.0 (无后缀)
|
│ └── PATCH 小版本号: 仅在明确提出时自增, 否则沿用 (记录见 CHANGELOG.md)
|
||||||
├─ 前端运行时 APP_VERSION = 0.2.0 (无后缀)
|
└── MINOR 大版本迭代, 对应一个开发分支 (V0.2 ↔ dev-0.2)
|
||||||
└─ OTA 热更包 version = 0.2.0-ota (多一个 -ota)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
版本号遵循 `主版本.次版本.修订号`,**唯一手动管理点是 `package.json` 的 `version` 字段**(当前 `0.2.0`),
|
**前端与外壳解绑,独立维护、独立发版**(2026-10-25 决议):
|
||||||
该字段只写裸版本号,**不允许带 `-ota`**——`-ota` 由 `scripts/build-ota.mjs` 打包时自动追加。
|
|
||||||
|
|
||||||
版本号必须与所在分支对应:`dev-0.2` 分支上就该是 `0.2.x`。
|
| 组 | 版本 | 管理点 | 产物 | 发版节奏 |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| **前端** (Web/OTA) | `0.2.0` | `package.json` 的 `version`(唯一手动管理点,只写裸版本号,不允许带 `-ota`) | 前端运行时 `APP_VERSION` = `0.2.0`;OTA 包 `version` = `0.2.0-ota` | 随 master push 高频发版 |
|
||||||
|
| **外壳** | `0.2.0` | `src-tauri/tauri.conf.json` 的 `version`(须与 `Cargo.toml`/`Cargo.lock` 组内一致) | APK versionName、桌面安装包版本;后续 Windows 外壳同理 | 独立分支(如 `android-*`)低频发版 |
|
||||||
|
|
||||||
|
- 两组版本号**不要求相等**,CI(`scripts/ci/check-version.mjs`)只校验组内一致 + 前端版本与 `dev-<major>.<minor>` 分支前缀对应。
|
||||||
|
- 分支口径:**dev-0.2 开发,发布一律走 master**。
|
||||||
|
|
||||||
|
### build 标识
|
||||||
|
|
||||||
|
- 格式统一为 **`YYYYMMDDHHmmss`(北京时间)**:
|
||||||
|
- 前端 `BUILD_ID`:vite 编译时生成(`vite.config.js#makeBuildId`),无需手动维护;
|
||||||
|
- OTA 包 `build_id`:打包时按同一格式生成(`scripts/build-ota.mjs`)。
|
||||||
|
- OTA 包的 `sequence`(Unix 毫秒,单调递增)保留,仅用于判新与热插拔协议,不再用于展示。
|
||||||
|
|
||||||
|
### PATCH 小版本号
|
||||||
|
|
||||||
|
- 仅在**明确提出**时自增(改 `package.json` version 第三位),否则一直沿用。
|
||||||
|
- 每次自增在 [CHANGELOG.md](CHANGELOG.md) 追加一行(patch 号 / 日期 / commit / 要点)。
|
||||||
|
|
||||||
### 改版本号时要同步的位置
|
### 改版本号时要同步的位置
|
||||||
|
|
||||||
| 文件 | 字段 | 说明 |
|
| 组 | 文件 | 字段 | 说明 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| `package.json` | `version` | **唯一源**,前端 `src/version.js` 与 OTA 打包脚本均读取此值 |
|
| 前端 | `package.json` | `version` | **前端唯一源**,`src/version.js` 与 OTA 打包脚本均读取此值 |
|
||||||
| `src-tauri/tauri.conf.json` | `version` | Tauri 应用版本 → **APK 的 versionName**、桌面安装包版本 |
|
| 外壳 | `src-tauri/tauri.conf.json` | `version` | APK 的 versionName、桌面安装包版本 |
|
||||||
| `src-tauri/Cargo.toml` | `[package].version` | Rust 包版本,需与 tauri.conf.json 一致 |
|
| 外壳 | `src-tauri/Cargo.toml` | `[package].version` | Rust 包版本,须与 tauri.conf.json 一致 |
|
||||||
| `src-tauri/Cargo.lock` | `name = "iboard"` 条目 | 同上,随 Cargo.toml 一起改 |
|
| 外壳 | `src-tauri/Cargo.lock` | `name = "iboard"` 条目 | 随 Cargo.toml 一起改 |
|
||||||
|
|
||||||
`scripts/ci/check-version.mjs` 会在 CI 第一段就校验这四处**完全相等**且不含 `-ota`,
|
|
||||||
并校验 `dev-<major>.<minor>` 分支与版本前缀对应;任何一项不满足直接构建失败(`main`/`master` 只跳过分支校验,一致性照样查)。
|
|
||||||
|
|
||||||
### APK 版本号是怎么来的
|
### APK 版本号是怎么来的
|
||||||
|
|
||||||
@@ -86,70 +122,36 @@ tauri.android.versionCode = 2000 # 整数,升级判定用 = major*1000000
|
|||||||
```
|
```
|
||||||
|
|
||||||
注意:
|
注意:
|
||||||
- `versionCode` 必须**单调递增**才能覆盖安装,所以版本号只能往上走(`0.2.0` → 2000 > `0.1.0` → 1000)。
|
- `versionCode` 必须**单调递增**才能覆盖安装,所以外壳版本号只能往上走(`0.2.0` → 2000 > `0.1.0` → 1000)。
|
||||||
- 本地如果 APK 装完显示旧版本号,是 `src-tauri/gen/android` 缓存没刷新,
|
- 本地如果 APK 装完显示旧版本号,是 `src-tauri/gen/android` 缓存没刷新,
|
||||||
删掉该目录重新 `npx tauri android init` 即可。
|
删掉该目录重新 `npx tauri android init` 即可。
|
||||||
|
|
||||||
### 其它
|
### 其它
|
||||||
|
|
||||||
- `src/version.js` 从 `package.json` 取值,前端"我"页与 OTA 检查接口展示的即此版本,不要另写死版本号。
|
- `src/version.js` 从 `package.json` 取值,前端运行时与 OTA 检查接口展示的即此版本,不要另写死版本号。
|
||||||
- OTA 升级包的 `version` = `<package.json version>-ota`(见 `scripts/build-ota.mjs`),
|
|
||||||
所以版本号错位会直接导致线上 OTA 判新失准。
|
|
||||||
- **`min_binary_version` 与这里的版本号是两件事**:它是 OTA 包要求的最低原生二进制版本
|
- **`min_binary_version` 与这里的版本号是两件事**:它是 OTA 包要求的最低原生二进制版本
|
||||||
(CI 里由 `OTA_MIN_BINARY` 显式指定,当前 `0.1.0`)。前端版本升到 0.2.0 时它不应跟着抬,
|
(CI 里由 `OTA_MIN_BINARY` 显式指定,当前 `0.1.0`)。前端版本升到 0.2.0 时它不应跟着抬,
|
||||||
否则装了 0.1.0 APK 的设备会直接收不到热更。
|
否则装了 0.1.0 APK 的设备会直接收不到热更。
|
||||||
- `BUILD_ID` 每次编译自动生成(见 `vite.config.js` 的 `__BUILD_ID__`),无需手动维护。
|
|
||||||
- `ota-package/latest.json` 为运行时产物,不纳入版本管理。
|
- `ota-package/latest.json` 为运行时产物,不纳入版本管理。
|
||||||
|
|
||||||
## 分支与 CI/CD (Gitea Actions)
|
### "我的"页版本展示口径
|
||||||
|
|
||||||
分支按版本命名:`dev-0.1`、`dev-0.2`…… 对应 `docs/V<版本>/` 下的需求/设计文档。
|
|
||||||
职责与合并方向见上文「分支规则」。
|
|
||||||
|
|
||||||
| 分支 | 触发的 workflow | 行为 |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| `master`(主分支) | `docker-build.yml` | 生产发布:构建前端 → 生成签名 OTA 包 → 打 Docker 镜像 → 数据库自动升级 → 重启容器 `iboard` (宿主 3001) |
|
|
||||||
| `dev-*` | **不触发部署** | 推送只走版本一致性校验;上线须先合并到 `master` |
|
|
||||||
| `tmp-*` | 不触发 | 临时分支,合到 `dev-*` 后删除 |
|
|
||||||
| `android-*` | `android-build.yml` | 打签名 release APK 并上传到 Gitea Generic Package Registry(构建前须与 `master` 同步) |
|
|
||||||
| 手动 | `ota-build.yml` / `sync-registry.yml` | 单独出 OTA 包 / 同步 secrets 到注册中心 |
|
|
||||||
|
|
||||||
数据库升级约定:
|
|
||||||
- 唯一入口是 `prisma/migrations/` 下的迁移文件,由 `npx prisma migrate deploy` 在**启新容器之前**执行
|
|
||||||
(幂等,无新迁移时为 no-op);迁移失败会中断 workflow,不会用新镜像顶掉仍在运行的旧容器。
|
|
||||||
- 由于只有 `master` 会部署,**迁移真正作用到生产库的唯一时机就是 master 发布**;
|
|
||||||
开发分支上的迁移不会被自动应用,请在合并到 `master` 前确认可回滚。
|
|
||||||
- 本地改了 `prisma/schema.prisma` 后:写迁移 SQL → `npx prisma migrate deploy` 验证 → 提交迁移目录。
|
|
||||||
|
|
||||||
- 首页主要是放经济文章
|
|
||||||
- 数据主要是放各种经济数据和图表。
|
|
||||||
- 策略是放按照经济理论组织的相关数据和图表的对比。
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## 首页
|
|
||||||
首页显示新闻模块和报警模块的数据,按照时间最新优先,无限滚动,直到数据库中没有数据为止。
|
|
||||||
|
|
||||||
|
|
||||||
### 新闻模块
|
|
||||||
增加一个新闻模块,数据来源是每日夜间 3点,通过妙想 MCP 获取国内外最新经济政治新闻,按影响大小排序10条。
|
|
||||||
存入数据库。
|
|
||||||
|
|
||||||
|
|
||||||
### 报警模块
|
|
||||||
World Bank 中的数据只用来对比妙想 MCP 拉取的数据正确性。当同一时间的同一项数据不同时,记录一个报警。
|
|
||||||
|
|
||||||
## 监控
|
|
||||||
监控分为服务器监控和服务监控两部分。
|
|
||||||
服务器监控对接 komari,地址:https://komari.honor3.com/
|
|
||||||
|
|
||||||
|
| 行 | 展示 |
|
||||||
|
| --- | --- |
|
||||||
|
| 当前版本 | `v0.2.0 · build 20261025204300`(前端 APP_VERSION + BUILD_ID 原始格式) |
|
||||||
|
| 运行资源 | OTA 覆盖包版本 / 内置 |
|
||||||
|
| 新版本 | `v0.2.0-ota · build 20261025210000`(OTA 包 build_id) |
|
||||||
|
| 发布说明 | 触发构建的 commit message |
|
||||||
|
| 提交 | commit sha 前 10 位 |
|
||||||
|
| 页面底部 | `外壳 v0.2.0`(Tauri API 读取;Web 端显示 `—`) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 前端 OTA 升级
|
## 前端 OTA 升级
|
||||||
|
|
||||||
只升级前端资源 (`dist/`), **不触碰** Tauri 框架/原生层, 不重打 APK.
|
只升级前端资源 (`dist/`),**不触碰** Tauri 框架/原生层,不重打 APK。
|
||||||
底层使用 [`tauri-plugin-hotswap`](https://github.com/denniskribl/tauri-plugin-hotswap)
|
底层使用 [`tauri-plugin-hotswap`](https://github.com/denniskribl/tauri-plugin-hotswap)
|
||||||
(签名/回滚/资源替换全部由插件处理).
|
(签名/回滚/资源替换全部由插件处理)。
|
||||||
|
|
||||||
### 工作流
|
### 工作流
|
||||||
|
|
||||||
@@ -170,6 +172,12 @@ scripts/build-ota.mjs │ 生成 iboard-ota.tar.gz + minisign 签名
|
|||||||
location.reload() ─▶ 后续资源从 tauri://localhost 优先命中覆盖包
|
location.reload() ─▶ 后续资源从 tauri://localhost 优先命中覆盖包
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### 发布说明与提交号(2026-10-25 起)
|
||||||
|
|
||||||
|
- CI(docker-build.yml)把**触发构建的 commit message**(标题+正文,去空行,截断 400 字)作为 `notes` 写入 latest.json;
|
||||||
|
- commit sha 经 `--commit` 参数单独写入 `commit` 字段;
|
||||||
|
- "我的"页:发布说明展示 commit message,「提交」行展示短 sha。
|
||||||
|
|
||||||
### 首次部署
|
### 首次部署
|
||||||
|
|
||||||
1. **生成签名密钥** (一次性):
|
1. **生成签名密钥** (一次性):
|
||||||
@@ -178,8 +186,7 @@ scripts/build-ota.mjs │ 生成 iboard-ota.tar.gz + minisign 签名
|
|||||||
node scripts/ota-genkey.mjs
|
node scripts/ota-genkey.mjs
|
||||||
# 产物: ota-package/hotswap.{key, pub}
|
# 产物: ota-package/hotswap.{key, pub}
|
||||||
```
|
```
|
||||||
2. **把公钥写入 `src-tauri/tauri.conf.json`** 的 `plugins.hotswap.pubkey` 字段
|
2. **把公钥写入 `src-tauri/tauri.conf.json`** 的 `plugins.hotswap.pubkey` 字段(脚本会打印)。
|
||||||
(脚本会打印).
|
|
||||||
3. **配置 endpoint** (`src-tauri/tauri.conf.json`):
|
3. **配置 endpoint** (`src-tauri/tauri.conf.json`):
|
||||||
```json
|
```json
|
||||||
"hotswap": {
|
"hotswap": {
|
||||||
@@ -188,7 +195,7 @@ scripts/build-ota.mjs │ 生成 iboard-ota.tar.gz + minisign 签名
|
|||||||
"require_https": true
|
"require_https": true
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
4. **服务端环境变量** `OTA_PACKAGE_DIR` 指向 `ota-package/` 所在目录.
|
4. **服务端环境变量** `OTA_PACKAGE_DIR` 指向 `ota-package/` 所在目录。
|
||||||
|
|
||||||
### 发布新版前端
|
### 发布新版前端
|
||||||
|
|
||||||
@@ -196,20 +203,20 @@ scripts/build-ota.mjs │ 生成 iboard-ota.tar.gz + minisign 签名
|
|||||||
npm run build # 生成 dist/
|
npm run build # 生成 dist/
|
||||||
node scripts/build-ota.mjs # 打成 tar.gz + 签名 + latest.json
|
node scripts/build-ota.mjs # 打成 tar.gz + 签名 + latest.json
|
||||||
# sequence 默认 = Date.now(), 也可 --sequence 43 手动指定
|
# sequence 默认 = Date.now(), 也可 --sequence 43 手动指定
|
||||||
# 也可加 --notes "修复登录按钮" --min-binary 0.1.0
|
# 也可加 --notes "修复登录按钮" --commit <sha> --min-binary 0.1.0
|
||||||
```
|
```
|
||||||
|
|
||||||
然后把 `ota-package/` 下两个文件 (`iboard-ota.tar.gz` + `latest.json`) 同步到
|
然后把 `ota-package/` 下两个文件 (`iboard-ota.tar.gz` + `latest.json`) 同步到
|
||||||
服务端 `OTA_PACKAGE_DIR` 指向的目录. sequence 单调递增即可.
|
服务端 `OTA_PACKAGE_DIR` 指向的目录。sequence 单调递增即可。
|
||||||
|
|
||||||
### 客户端使用
|
### 客户端使用
|
||||||
|
|
||||||
打开 App 的"我"页, 点 [检查更新] → [立即升级] 即可. 失败会自动回滚到内置 dist.
|
打开 App 的"我"页,点 [检查更新] → [立即升级] 即可。失败会自动回滚到内置 dist。
|
||||||
|
|
||||||
> **注意:** Tauri 2 的 dev 模式 (`tauri dev`) 会代理到 Vite dev server, **OTA 不生效**.
|
> **注意:** Tauri 2 的 dev 模式 (`tauri dev`) 会代理到 Vite dev server,**OTA 不生效**。
|
||||||
> OTA 仅在 release/打包构建中可用, 测试请用 `tauri build` 产物.
|
> OTA 仅在 release/打包构建中可用,测试请用 `tauri build` 产物。
|
||||||
|
|
||||||
### 客户端判新口径(V0.2 新增)
|
### 客户端判新口径(V0.2)
|
||||||
|
|
||||||
"我"页 OTA 组内有一个 **开发版本** 开关(默认关闭),切换"怎么算新版本":
|
"我"页 OTA 组内有一个 **开发版本** 开关(默认关闭),切换"怎么算新版本":
|
||||||
|
|
||||||
@@ -226,25 +233,31 @@ node scripts/build-ota.mjs # 打成 tar.gz + 签名 + latest.json
|
|||||||
|
|
||||||
### Web 端
|
### Web 端
|
||||||
|
|
||||||
Web 浏览器访问 (同源) 时, 服务端返回简化版 manifest, 仅显示"有新版本请刷新".
|
Web 浏览器访问 (同源) 时,服务端返回简化版 manifest,仅显示"有新版本请刷新"。
|
||||||
Web 端不会自动下载包, 也不会替换前端代码.
|
Web 端不会自动下载包,也不会替换前端代码。
|
||||||
|
|
||||||
|
|
||||||
### CI 自动签名 (Gitea Actions)
|
### CI 自动签名 (Gitea Actions)
|
||||||
|
|
||||||
.gitea/workflows/ota-build.yml 是手动触发的 OTA 打包工作流 (每次发版前点一次).
|
.gitea/workflows/ota-build.yml 是手动触发的 OTA 打包工作流(每次发版前点一次)。
|
||||||
|
|
||||||
**配置 secret** (Gitea 仓库 -> Settings -> Actions -> Secrets):
|
**配置 secret** (Gitea 仓库 -> Settings -> Actions -> Secrets):
|
||||||
- MINISIGN_PRIVATE_KEY: 完整 .key 文件内容, **整段** (含首行 `untrusted comment: minisign encrypted secret key` 和 base64 行), 整段作为多行 secret 粘贴即可.
|
- MINISIGN_PRIVATE_KEY: 完整 .key 文件内容,**整段**(含首行 `untrusted comment: minisign encrypted secret key` 和 base64 行),整段作为多行 secret 粘贴即可。
|
||||||
|
|
||||||
**使用流程**:
|
**使用流程**:
|
||||||
1. 在 Gitea Actions 页面选 OTA Frontend Build & Sign -> Run workflow
|
1. 在 Gitea Actions 页面选 OTA Frontend Build & Sign -> Run workflow
|
||||||
2. 填 sequence (可选, 不填用时间戳) 和
|
2. 填 sequence(可选,不填用时间戳)和 notes(可选)
|
||||||
otes (可选)
|
|
||||||
3. CI 跑完会在 artifact 里看到 ota-package/iboard-ota.tar.gz + latest.json + iboard-ota.tar.gz.minisig
|
3. CI 跑完会在 artifact 里看到 ota-package/iboard-ota.tar.gz + latest.json + iboard-ota.tar.gz.minisig
|
||||||
4. 下载 artifact, scp 到服务器 OTA_PACKAGE_DIR 目录
|
4. 下载 artifact,scp 到服务器 OTA_PACKAGE_DIR 目录
|
||||||
5. 客户端"我"页 [检查更新] 即可触发升级
|
5. 客户端"我"页 [检查更新] 即可触发升级
|
||||||
|
|
||||||
**注意**:
|
**注意**:
|
||||||
- CI 用的私钥必须**与** auri.conf.json 里 plugins.hotswap.pubkey **配对**; 换密钥时需要同时更新 CI secret 和 conf.
|
- CI 用的私钥必须**与** `tauri.conf.json` 里 plugins.hotswap.pubkey **配对**;换密钥时需要同时更新 CI secret 和 conf。
|
||||||
- 不要把私钥 commit 到 git, 整个 ota-package/hotswap.key 已被 .gitignore 排除.
|
- 不要把私钥 commit 到 git,整个 ota-package/hotswap.key 已被 .gitignore 排除。
|
||||||
|
|
||||||
|
### 数据库升级约定
|
||||||
|
|
||||||
|
- 唯一入口是 `prisma/migrations/` 下的迁移文件,由 `npx prisma migrate deploy` 在**启新容器之前**执行
|
||||||
|
(幂等,无新迁移时为 no-op);迁移失败会中断 workflow,不会用新镜像顶掉仍在运行的旧容器。
|
||||||
|
- 由于只有 `master` 会部署,**迁移真正作用到生产库的唯一时机就是 master 发布**;
|
||||||
|
开发分支上的迁移不会被自动应用,请在合并到 `master` 前确认可回滚。
|
||||||
|
- 本地改了 `prisma/schema.prisma` 后:写迁移 SQL → `npx prisma migrate deploy` 验证 → 提交迁移目录。
|
||||||
@@ -155,6 +155,9 @@
|
|||||||
| T-TK-11 | `TaskMember` 关联表 | 多对多,`@@unique([taskId,userId])` 去重 |
|
| T-TK-11 | `TaskMember` 关联表 | 多对多,`@@unique([taskId,userId])` 去重 |
|
||||||
| T-TK-12 | `TaskTag` 表 + 卡片标签渲染 | 多标签,展示于卡片/条目 |
|
| T-TK-12 | `TaskTag` 表 + 卡片标签渲染 | 多标签,展示于卡片/条目 |
|
||||||
| T-TK-13 | 服务端维护 | `creatorId`/`createdAt` 不可改;`updatedAt` 自动刷新 |
|
| T-TK-13 | 服务端维护 | `creatorId`/`createdAt` 不可改;`updatedAt` 自动刷新 |
|
||||||
|
| T-TK-14 | 标题 30 字上限 + 快速添加截断 | 服务端 create/update 校验 `title ≤ 30`;前端 `quickAdd.js#splitTitle`:≤30 原样,超长在 30 字内(≥8 字)找最后一个分隔符截断,前段为标题、**完整原文**写入 `description`;快速添加输入框 `maxLength=200` |
|
||||||
|
| T-TK-15 | `Task.markDate DATETIME(3) NULL`(迁移 20261025120000) | create/update 接受 `YYYY-MM-DD`(空/null 清除),完整日期存储;详情页 DatePicker(precision=day)选择/清除;卡片展示 `MM-dd` 小标签(`date-tag`) |
|
||||||
|
| T-TK-16 | `quickAdd.js#parseTimePrefix` | 前缀词表:今天/明天/后天/本周/下周/本月/下月(须后跟分隔符或结束);今天=当日,明天/后天=+1/+2,本周=本周日、下周=下周日(周一为一周开始),本月/下月=当月/下月最后一天;命中后前缀移出标题并写入 `markDate` |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -176,8 +176,10 @@
|
|||||||
| `projectId` | `INT` | 否 | — | FK → `Project.id` `ON DELETE CASCADE` |
|
| `projectId` | `INT` | 否 | — | FK → `Project.id` `ON DELETE CASCADE` |
|
||||||
| `columnId` | `INT` | 否 | — | FK → `BoardColumn.id` `ON DELETE RESTRICT`(T-BD-01) |
|
| `columnId` | `INT` | 否 | — | FK → `BoardColumn.id` `ON DELETE RESTRICT`(T-BD-01) |
|
||||||
| `parentId` | `INT` | **是** | `NULL` | 非空 = 一级子任务;FK → `Task.id` `ON DELETE CASCADE`(T-ST-01/11) |
|
| `parentId` | `INT` | **是** | `NULL` | 非空 = 一级子任务;FK → `Task.id` `ON DELETE CASCADE`(T-ST-01/11) |
|
||||||
| `title` | `VARCHAR(200)` | 否 | — | 唯一必填(T-TK-02) |
|
| `title` | `VARCHAR(200)` | 否 | — | 唯一必填(T-TK-02);应用层限 30 字(T-TK-14) |
|
||||||
| `description` | `TEXT` | 否 | `''` | — |
|
| `description` | `TEXT` | 否 | `''` | 快速添加超长文本的余文并入此处(T-TK-14) |
|
||||||
|
| `kind` | `VARCHAR(20)` | **是** | `NULL` | 任务类型标签,取值见 `Project.taskKinds` |
|
||||||
|
| `markDate` | `DATETIME(3)` | **是** | `NULL` | 时间标记,完整日期存储、展示 `MM-dd`(T-TK-15;迁移 20261025120000) |
|
||||||
| `status` | `VARCHAR(8)` | 否 | `'todo'` | `todo`/`doing`/`done`(T-TK-03) |
|
| `status` | `VARCHAR(8)` | 否 | `'todo'` | `todo`/`doing`/`done`(T-TK-03) |
|
||||||
| `doneAt` | `DATETIME(3)` | **是** | `NULL` | 完成时刻;**保留选项判定依据**(T-FL-10) |
|
| `doneAt` | `DATETIME(3)` | **是** | `NULL` | 完成时刻;**保留选项判定依据**(T-FL-10) |
|
||||||
| `assigneeId` | `INT` | **是** | `NULL` | 负责人(单人);新建默认创建者(T-TK-09) |
|
| `assigneeId` | `INT` | **是** | `NULL` | 负责人(单人);新建默认创建者(T-TK-09) |
|
||||||
|
|||||||
@@ -240,6 +240,9 @@
|
|||||||
| T-TK-11 | 每条任务有**任务成员(多人)** | 可设置 0 ~ 多个成员,成员不重复 | P0 |
|
| T-TK-11 | 每条任务有**任务成员(多人)** | 可设置 0 ~ 多个成员,成员不重复 | P0 |
|
||||||
| T-TK-12 | 任务支持**标签** | 可添加多个标签,展示于条目 / 卡片 | P1 |
|
| T-TK-12 | 任务支持**标签** | 可添加多个标签,展示于条目 / 卡片 | P1 |
|
||||||
| T-TK-13 | 任务的创建人、创建时间、更新时间由系统维护,用户不可直接编辑 | 编辑任务不改变创建人与创建时间;任何修改刷新更新时间 | P1 |
|
| T-TK-13 | 任务的创建人、创建时间、更新时间由系统维护,用户不可直接编辑 | 编辑任务不改变创建人与创建时间;任何修改刷新更新时间 | P1 |
|
||||||
|
| T-TK-14 | 任务标题收紧至**30 个汉字**;快速添加输入框允许输入 200 字,超过 30 字时自动在 30 字以内的**分隔符(空格/标点等)**处截断,前段作为标题,整体文本并入描述 | 输入长文本创建后:标题 ≤30 字且不在词中间切断;描述包含完整原文余文;直接编辑标题超 30 字时拒绝保存并提示 | P0 |
|
||||||
|
| T-TK-15 | 任务可选**时间标记**(完整日期存储),设置了时间标记的任务在列表行与看板卡片上以 `MM-dd` 小标签展示 | 详情页可选择/清除时间标记;设置后卡片出现 MM-dd 标签,清除后消失 | P1 |
|
||||||
|
| T-TK-16 | 快速添加输入框支持**时间词前缀**:以 今天/明天/后天/本周/下周/本月/下月 开头(后跟分隔符)时,自动为任务设置对应时间标记并把该前缀从标题中移除 | 输入「明天 交周报」→ 创建任务标题为「交周报」、时间标记为明天的日期;无匹配前缀时行为不变 | P1 |
|
||||||
|
|
||||||
### 3.6 负责人 & 成员 UI 展示规则(T-AS)
|
### 3.6 负责人 & 成员 UI 展示规则(T-AS)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
# 基于 PowerSync 的离线编辑可行性研究报告
|
||||||
|
|
||||||
|
| 项 | 内容 |
|
||||||
|
| --- | --- |
|
||||||
|
| 版本 | V0.2 |
|
||||||
|
| 日期 | 2026-10-25 |
|
||||||
|
| 评估对象 | PowerSync(powersync.com,开源 self-hosted + 云服务) |
|
||||||
|
| 评估目标 | 项目离线操作(本地编辑)+ 联网后自动同步 |
|
||||||
|
| 结论(TL;DR) | **技术上可行,但落地成本高、风险中高。不建议全量接入;若确需离线,建议仅对「打卡 / 清单 / 任务」等用户编辑域做局部接入,且需要先解决 MariaDB 兼容与主键策略两个前置问题。** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 项目现状约束(评估输入)
|
||||||
|
|
||||||
|
结合代码库(`package.json`、`prisma/schema.prisma`、`server/index.js`)梳理:
|
||||||
|
|
||||||
|
1. **前端**:React 18 SPA(antd-mobile),通过 axios 调 `/api/*` REST 接口;`src/api`、`src/hooks`、`src/pages` 均以"请求 → state → 渲染"模式组织,无任何本地持久化数据层。
|
||||||
|
2. **桌面/移动端**:Tauri 2(Windows WebView2 桌面 + Android System WebView),意味着前端运行在 WebView 内,浏览器类 SDK 是候选。
|
||||||
|
3. **后端**:Express + Prisma,数据库为 **MariaDB / MySQL**(`provider = "mysql"`)。
|
||||||
|
4. **认证**:自签 JWT(jsonwebtoken),登录接口在 `/api/auth`。
|
||||||
|
5. **写路径**:全部业务写操作走 REST(打卡、清单、任务模块);同时存在大量**服务端定时写入**(cron:新闻、指标、监控、米家采样等,见 `server/cron.js`)。
|
||||||
|
6. **数据模型**:约 25 张表,主键全部为 `Int @id @default(autoincrement())` 自增整数,多表带复合唯一约束(如 `CheckIn @@unique([habitId, date, seq])`)。
|
||||||
|
7. **数据体量特征**:两类数据并存——
|
||||||
|
- **用户编辑域**(Habit / CheckIn / CheckList* / 任务模块 10 张表):冲突敏感、离线价值高;
|
||||||
|
- **服务端只读时序域**(News / Indicator / ServerNodeHistory / MihomeEnv* 等):append-only、量大、离线价值中等(缓存即可满足)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. PowerSync 是什么、怎么工作
|
||||||
|
|
||||||
|
- 架构:客户端本地 **SQLite**(Web 端为 WASM SQLite,存 OPFS/IndexedDB)↔ **PowerSync Service**(self-hosted 开源组件或云服务)↔ 源数据库。服务端通过 **逻辑复制/变更流** 捕获源库写入,按 **Sync Rules** 定义的下推子集同步到每个客户端的本地 SQLite。
|
||||||
|
- 写路径(官方推荐模式):客户端离线写本地 SQLite → 进入 **upload queue** → 联网后由前端把变更批量 POST 到**现有后端 API** → 后端用原有方式(本项目即 Prisma)写库 → 变更流捕获 → 广播回各端。
|
||||||
|
- 认证:客户端持后端签发的 JWT 连接同步服务;PowerSync 只做校验,不签发(可复用现有 jsonwebtoken 体系)。
|
||||||
|
- 服务端直写的表(cron 产生)也能被变更流捕获并下推,不需要改造写入方。
|
||||||
|
|
||||||
|
参考:[PowerSync Service(GitHub)](https://github.com/powersync-ja/powersync-service)、[源数据库支持](https://docs.powersync.com/configuration/source-db/setup)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 逐项可行性分析
|
||||||
|
|
||||||
|
### 3.1 数据库兼容性 —— ⚠️ 主要风险点
|
||||||
|
|
||||||
|
- PowerSync 对源库支持:**PostgreSQL(GA)**、MongoDB、SQL Server;**MySQL 仍处于 Beta**(基于 binlog 复制,见 [MySQL Beta 公告](https://releases.powersync.com/announcements/mysql-backend-database-support-has-reached-beta)、[Alpha 公告](https://releases.powersync.com/announcements/announcing-mysql-as-a-source-database-alpha))。
|
||||||
|
- 本项目用的是 **MariaDB**。MariaDB 的 binlog 格式与 MySQL 8 有差异(GTID、行事件细节),PowerSync MySQL 插件(@powersync/mysql-zongji 系)对 MariaDB **没有官方兼容承诺,需要实测验证**,很可能踩坑。
|
||||||
|
- **结论**:要么实测 MariaDB + MySQL-beta 通路(风险高),要么把数据库迁到 PostgreSQL(PowerSync 最成熟路径,Prisma 迁移成本可控但涉及全部表和线上数据)。这是"能不能用"的第一道门槛。
|
||||||
|
|
||||||
|
### 3.2 数据模型适配 —— 改造成本大
|
||||||
|
|
||||||
|
- PowerSync Sync Rules 要求同步表有稳定的行标识,**强烈建议 UUID 文本主键**;而本项目全部是自增整数主键,且客户端离线创建记录时无法预知自增 id。
|
||||||
|
- 离线创建的通用解法:主键改为客户端生成的 UUID(`char(36)`),或新增 `uuid` 列 + 应用层幂等 upsert。对用户编辑域的 10 余张表意味着**一次不小的 schema 迁移 + 全部相关路由改造**(迁移原则"只增不改"仍可遵守,但外键列类型变更工作量大)。
|
||||||
|
- `DateTime`、`Json` 类型可映射(客户端以 ISO 文本/JSON 文本存储),但 `BigInt`(监控表)在 JS 端需按文本处理,注意精度。
|
||||||
|
- 复合唯一约束(`CheckIn(habitId,date,seq)`、`BoardColumn(projectId,name)` 等)在离线多端并发下会冲突,需在 upload 处理端路由里做服务端仲裁(如 seq 重排、重名加后缀),现有路由大多不具备幂等/仲裁语义。
|
||||||
|
|
||||||
|
### 3.3 写路径(离线编辑 + 上行同步)—— 模式契合,但需逐路由改造
|
||||||
|
|
||||||
|
- PowerSync 的 upload-queue → 后端 API 模式与现有 Express 架构**天然契合**:不需要后端暴露直接写库的通口,各业务路由补成"接收客户端 UUID + 幂等 upsert"即可。
|
||||||
|
- 打卡/清单/任务的路由数量约 3 个 router 文件(checkin / checklists / projects / tasks),逐个改造为幂等是可控的。
|
||||||
|
- 服务端 cron 写入的表(新闻/指标/监控/米家)无需改造写入方,只需在 Sync Rules 里定义是否下推。
|
||||||
|
|
||||||
|
### 3.4 读路径与前端数据层 —— 全面前重写
|
||||||
|
|
||||||
|
这是**工作量最大**的部分:
|
||||||
|
|
||||||
|
- 现有 `src/api` + axios + useEffect 模式必须替换为 PowerSync 本地 SQLite 查询 + `@powersync/react` 的响应式 hooks(useQuery 等),否则离线读不到数据。
|
||||||
|
- 涉及 `src/pages` 下全部用户编辑域页面(checkin、task),以及若要离线浏览还需覆盖 news/macro/monitor 的只读缓存页。
|
||||||
|
- 工作量量级:相当于把前端数据层重构一遍;可与"离线只读缓存(HTTP 缓存)"分层实施以降低风险。
|
||||||
|
|
||||||
|
### 3.5 客户端运行环境 —— 大体可行,Android 需验证
|
||||||
|
|
||||||
|
- **浏览器 / Tauri 桌面(WebView2)**:PowerSync Web SDK(WASM SQLite + OPFS)在 Chromium 系 WebView 已被官方支持,可行。
|
||||||
|
- **Tauri Android(System WebView)**:WASM SQLite 性能与 OPFS 在部分国产/旧版 WebView 上存在兼容性风险,需真机验证;备选方案是封装原生 SQLite(官方 Capacitor 插件思路可借鉴,但 Tauri 无官方插件,需自研 Rust 命令桥接,成本额外增加)。
|
||||||
|
- 时序大表(ServerNodeHistory 每分钟一条、MihomeEnv*)不建议纳入同步——Sync Rules 需做时间窗过滤(如仅近 7 天),否则客户端存储和同步流量都会失控。
|
||||||
|
|
||||||
|
### 3.6 认证与部署 —— 可行
|
||||||
|
|
||||||
|
- JWT 可复用:新增一个 `/api/powersync/token` 端点为已登录用户签发 PowerSync 接受的 token,改造小。
|
||||||
|
- 部署:self-hosted 需在现有 Express 之外多跑一个 powersync-service 进程(含 Mongo 存储元数据或云实例),并暴露 WebSocket 端口;或使用 PowerSync Cloud。运维面增加一个组件。
|
||||||
|
|
||||||
|
### 3.7 冲突解决
|
||||||
|
|
||||||
|
- PowerSync 本身不做自动合并,只提供上传队列与冲突回调;对"最后写入胜出"友好的字段(清单勾选、任务标题/状态)默认策略够用;对 `CheckIn.seq`、`BoardColumn.sortOrder` 这类"位置/序号型"数据需要自定义仲裁逻辑(上传端或同步流中处理),设计上可解,但要专门做。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 风险与成本汇总
|
||||||
|
|
||||||
|
| # | 风险/成本项 | 等级 | 说明 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 1 | MariaDB 兼容性未验证,MySQL 支持仅 Beta | 高 | 很可能需要迁移 PostgreSQL |
|
||||||
|
| 2 | 前端数据层全面重构(axios → 本地 SQLite 查询) | 高 | 涉及所有相关页面 |
|
||||||
|
| 3 | 用户编辑域主键整数自增 → UUID 迁移 | 中高 | schema + 路由幂等改造 |
|
||||||
|
| 4 | 复合唯一约束的离线冲突仲裁 | 中 | CheckIn.seq、列名、排序等 |
|
||||||
|
| 5 | Android WebView WASM SQLite/OPFS 兼容 | 中 | 需真机验证 |
|
||||||
|
| 6 | 新增 self-hosted 组件的运维 | 中 | 多一个常驻服务 |
|
||||||
|
| 7 | 时序大表同步范围控制 | 低 | Sync Rules 时间窗过滤即可 |
|
||||||
|
|
||||||
|
对照收益:真正需要离线**编辑**的只有打卡 / 清单 / 任务三个域(数据量小、单用户为主、冲突场景有限);其余域离线**只读缓存**即可满足。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 结论与建议
|
||||||
|
|
||||||
|
### 5.1 结论
|
||||||
|
|
||||||
|
**可行,但不建议以"全项目接入 PowerSync"的方式落地。** PowerSync 的同步模型与项目的 Express API 写路径契合、认证可复用、桌面端可行;但 MariaDB/MySQL-beta 兼容、整数自增主键、前端数据层全面重构三座大山使总成本明显高于当前阶段的收益。
|
||||||
|
|
||||||
|
### 5.2 建议(按优先序)
|
||||||
|
|
||||||
|
1. **首选(低成本方案)**:不引入 PowerSync,为用户编辑域实现轻量离线队列——前端把写请求落到 localStorage/IndexedDB 待发队列(含客户端生成的 UUID),联网后重放到现有 REST API(路由补幂等 upsert);只读页用 HTTP 缓存 + SW。可覆盖约 80% 的离线诉求,改动局限在 `src/api` 层与少量路由。
|
||||||
|
2. **若决定采用 PowerSync**:
|
||||||
|
- 前置 1:源库迁移 PostgreSQL(避开 MySQL Beta + MariaDB 兼容雷区);
|
||||||
|
- 前置 2:仅将用户编辑域(Habit/CheckIn/CheckList*/任务模块)纳入 Sync Rules,时序与监控表排除或做时间窗过滤;
|
||||||
|
- 前置 3:这些表的主键/唯一标识改造为客户端可生成的 UUID;
|
||||||
|
- 分阶段:先桌面端(WebView2)验证全链路,再攻 Android WebView 兼容性;
|
||||||
|
- 冲突策略:字段级 LWW 为主,seq/sortOrder 类服务端仲裁。
|
||||||
|
3. **不建议**:为离线只读域(新闻/指标/监控)上 PowerSync——用简单缓存即可,同步引擎属于过度设计。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 参考资料
|
||||||
|
|
||||||
|
- [PowerSync Service(GitHub,self-hosted 开源)](https://github.com/powersync-ja/powersync-service)
|
||||||
|
- [PowerSync 源数据库配置文档](https://docs.powersync.com/configuration/source-db/setup)
|
||||||
|
- [MySQL 后端支持 Beta 公告](https://releases.powersync.com/announcements/mysql-backend-database-support-has-reached-beta)
|
||||||
|
- [MySQL 后端支持 Alpha 公告](https://releases.powersync.com/announcements/announcing-mysql-as-a-source-database-alpha)
|
||||||
|
- 本项目:`prisma/schema.prisma`、`server/index.js`、`docs/V0.2/数据库设计文档.md`、`docs/V0.2/任务模块/任务模块数据库设计文档.md`
|
||||||
+1
-1
@@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover, interactive-widget=resizes-content" />
|
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover" />
|
||||||
<meta name="theme-color" content="#1f2937" />
|
<meta name="theme-color" content="#1f2937" />
|
||||||
<title>iBoard - 经济数据与策略</title>
|
<title>iBoard - 经济数据与策略</title>
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-- 任务类型标签: 项目级自定义列表 + 任务上的类型
|
||||||
|
ALTER TABLE `Project` ADD COLUMN `taskKinds` JSON NULL;
|
||||||
|
ALTER TABLE `Task` ADD COLUMN `kind` VARCHAR(20) NULL;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- 任务时间标记: 完整日期存储, 前端展示 MM-dd
|
||||||
|
ALTER TABLE `Task` ADD COLUMN `markDate` DATETIME(3) NULL;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- 项目图标: data URL (客户端裁剪 96x96 JPEG, 约 10-20KB)
|
||||||
|
ALTER TABLE `Project` ADD COLUMN `icon` TEXT NULL;
|
||||||
@@ -394,6 +394,8 @@ model Project {
|
|||||||
isPersonal Boolean @default(false)
|
isPersonal Boolean @default(false)
|
||||||
archived Boolean @default(false)
|
archived Boolean @default(false)
|
||||||
notes Json @default("[]") // 项目信息块 [{ title, content }] (T-PJ-13)
|
notes Json @default("[]") // 项目信息块 [{ title, content }] (T-PJ-13)
|
||||||
|
taskKinds Json? // 任务类型标签列表 (未配置时服务端回落默认四类)
|
||||||
|
icon String? @db.Text // 项目图标 (data URL, 客户端裁剪为 96x96 JPEG)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@@ -447,6 +449,8 @@ model Task {
|
|||||||
|
|
||||||
title String @db.VarChar(200)
|
title String @db.VarChar(200)
|
||||||
description String @default("") @db.Text
|
description String @default("") @db.Text
|
||||||
|
kind String? @db.VarChar(20) // 任务类型标签 (项目 taskKinds 之一)
|
||||||
|
markDate DateTime? // 时间标记 (完整日期存储, 展示为 MM-dd)
|
||||||
status String @default("todo") @db.VarChar(8)
|
status String @default("todo") @db.VarChar(8)
|
||||||
doneAt DateTime?
|
doneAt DateTime?
|
||||||
|
|
||||||
|
|||||||
+13
-3
@@ -29,6 +29,8 @@ const OUT_DIR = path.resolve(getArg('--out', 'ota-package'))
|
|||||||
const SEQUENCE = Number(getArg('--sequence', String(Date.now()))) // 默认用时间戳做单调计数
|
const SEQUENCE = Number(getArg('--sequence', String(Date.now()))) // 默认用时间戳做单调计数
|
||||||
const MIN_BINARY = getArg('--min-binary', '')
|
const MIN_BINARY = getArg('--min-binary', '')
|
||||||
const NOTES = getArg('--notes', '')
|
const NOTES = getArg('--notes', '')
|
||||||
|
// 构建对应的 git commit sha (发布说明=commit message, sha 单独一行展示)
|
||||||
|
const COMMIT = getArg('--commit', '')
|
||||||
const NO_SIGN = args.includes('--no-sign')
|
const NO_SIGN = args.includes('--no-sign')
|
||||||
// 私钥优先来源: MINISIGN_PRIVATE_KEY 环境变量 (CI 注入) → 文件 (--key 路径)
|
// 私钥优先来源: MINISIGN_PRIVATE_KEY 环境变量 (CI 注入) → 文件 (--key 路径)
|
||||||
const KEY_PATH = path.resolve(getArg('--key', path.join(OUT_DIR, 'hotswap.key')))
|
const KEY_PATH = path.resolve(getArg('--key', path.join(OUT_DIR, 'hotswap.key')))
|
||||||
@@ -117,11 +119,17 @@ const manifest = {
|
|||||||
bundle_size: bundleStat.size,
|
bundle_size: bundleStat.size,
|
||||||
bundle_sha256: bundleSha256,
|
bundle_sha256: bundleSha256,
|
||||||
signature,
|
signature,
|
||||||
notes: NOTES,
|
notes: String(NOTES).slice(0, 400),
|
||||||
|
commit: COMMIT || undefined,
|
||||||
pub_date: new Date().toISOString(),
|
pub_date: new Date().toISOString(),
|
||||||
mandatory: false,
|
mandatory: false,
|
||||||
// 内部用: 前端 web 模式比对 build_id
|
// 内部用: 前端 web 模式比对 build_id。
|
||||||
build_id: 'seq-' + SEQUENCE,
|
// 格式与前端 vite BUILD_ID 一致 (YYYYMMDDHHmmss, 北京时间); sequence 保持毫秒单调递增
|
||||||
|
build_id: (() => {
|
||||||
|
const d = new Date(Date.now() + 8 * 3600 * 1000)
|
||||||
|
const p = (n, w) => String(n).padStart(w || 2, '0')
|
||||||
|
return `${d.getUTCFullYear()}${p(d.getUTCMonth() + 1)}${p(d.getUTCDate())}${p(d.getUTCHours())}${p(d.getUTCMinutes())}${p(d.getUTCSeconds())}`
|
||||||
|
})(),
|
||||||
}
|
}
|
||||||
fs.writeFileSync(path.join(OUT_DIR, 'latest.json'), JSON.stringify(manifest, null, 2))
|
fs.writeFileSync(path.join(OUT_DIR, 'latest.json'), JSON.stringify(manifest, null, 2))
|
||||||
|
|
||||||
@@ -131,6 +139,8 @@ console.log('=== OTA 包生成完成 ===')
|
|||||||
console.log('输出目录: ', OUT_DIR)
|
console.log('输出目录: ', OUT_DIR)
|
||||||
console.log('版本: ', manifest.version)
|
console.log('版本: ', manifest.version)
|
||||||
console.log('sequence: ', manifest.sequence)
|
console.log('sequence: ', manifest.sequence)
|
||||||
|
console.log('build_id: ', manifest.build_id)
|
||||||
|
console.log('commit: ', manifest.commit || '(未指定)')
|
||||||
console.log('bundle 大小: ', (bundleStat.size / 1024).toFixed(1) + ' KB')
|
console.log('bundle 大小: ', (bundleStat.size / 1024).toFixed(1) + ' KB')
|
||||||
console.log('bundle sha256:', bundleSha256)
|
console.log('bundle sha256:', bundleSha256)
|
||||||
console.log('签名: ', signature ? '(已生成, 写入 latest.json)' : '(未签名, --no-sign)')
|
console.log('签名: ', signature ? '(已生成, 写入 latest.json)' : '(未签名, --no-sign)')
|
||||||
|
|||||||
@@ -1,17 +1,24 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
// CI 专用: 校验版本号四处一致 + 与分支名一致
|
// CI 专用: 校验版本号分组一致 + 前端版本与分支名对应
|
||||||
//
|
//
|
||||||
// 背景:
|
// 背景 (2026-10-25 调整: 外壳与前端版本号解绑):
|
||||||
// 版本号只允许一个手工管理点 = package.json 的 version, 其余位置全部派生:
|
// 前端 (Web/OTA) 与外壳 (Android/Windows 壳) 独立维护、独立发版,
|
||||||
// - APK / 桌面安装包 version = <version> (裸版本号, 无后缀)
|
// 两组版本号不再要求相等, 只要求**组内**一致:
|
||||||
// - OTA 热更包 version = <version>-ota (多一个 -ota 标记)
|
//
|
||||||
// - 前端运行时 APP_VERSION = <version>
|
// 组1 前端: package.json .version
|
||||||
// 任何一处漏改都会让线上版本号、APK 版本、OTA 判新三者对不上, 且很难在客户端发现。
|
// ├─ 前端运行时 APP_VERSION = <version> (裸, 无后缀)
|
||||||
|
// └─ OTA 热更包 version = <version>-ota (打包脚本追加)
|
||||||
|
//
|
||||||
|
// 组2 外壳: src-tauri/tauri.conf.json .version (= APK versionName)
|
||||||
|
// ├─ src-tauri/Cargo.toml [package].version
|
||||||
|
// └─ src-tauri/Cargo.lock (iboard 条目)
|
||||||
|
// 外壳随 android-*/桌面发布独立演进, 与 package.json 无关。
|
||||||
|
//
|
||||||
|
// 另: package.json 的 version 不允许带 "-ota" (OTA 后缀由打包脚本追加)。
|
||||||
//
|
//
|
||||||
// 校验分两段:
|
// 校验分两段:
|
||||||
// A. 一致性 (任何分支都查): package.json ↔ tauri.conf.json ↔ Cargo.toml ↔ Cargo.lock
|
// A. 组内一致性 (任何分支都查)
|
||||||
// 并要求 package.json 的 version 不含 "-ota" (裸版本, OTA 后缀由打包脚本追加)
|
// B. 分支对应 (仅 dev-<版本> 分支, 针对前端组):
|
||||||
// B. 分支对应 (仅 dev-<版本> 分支):
|
|
||||||
// dev-<major>.<minor> → version 必须以 <major>.<minor> 开头
|
// dev-<major>.<minor> → version 必须以 <major>.<minor> 开头
|
||||||
// dev-<major> → version 必须以 <major>. 开头
|
// dev-<major> → version 必须以 <major>. 开头
|
||||||
// main / master / 其它 → 跳过
|
// main / master / 其它 → 跳过
|
||||||
@@ -53,61 +60,59 @@ function readCargoLockVersion() {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
const sources = [
|
// 组1 前端 (唯一手动管理点 = package.json)
|
||||||
{ file: 'package.json', version: String(readJson('package.json').version || '') },
|
const webVersion = String(readJson('package.json').version || '')
|
||||||
|
const webSources = [{ file: 'package.json', version: webVersion }]
|
||||||
|
const otaVersion = webVersion + '-ota'
|
||||||
|
|
||||||
|
// 组2 外壳 (独立维护; 三处必须组内一致)
|
||||||
|
const shellSources = [
|
||||||
{ file: 'src-tauri/tauri.conf.json', version: String(readJson('src-tauri/tauri.conf.json').version || '') },
|
{ file: 'src-tauri/tauri.conf.json', version: String(readJson('src-tauri/tauri.conf.json').version || '') },
|
||||||
{ file: 'src-tauri/Cargo.toml', version: readCargoTomlVersion() },
|
{ file: 'src-tauri/Cargo.toml', version: readCargoTomlVersion() },
|
||||||
{ file: 'src-tauri/Cargo.lock (iboard)', version: readCargoLockVersion() },
|
{ file: 'src-tauri/Cargo.lock (iboard)', version: readCargoLockVersion() },
|
||||||
]
|
]
|
||||||
|
|
||||||
const version = sources[0].version
|
|
||||||
const otaVersion = version + '-ota'
|
|
||||||
|
|
||||||
console.log('[check-version] 分支: ', branch || '(未知)')
|
console.log('[check-version] 分支: ', branch || '(未知)')
|
||||||
for (const s of sources) {
|
for (const s of webSources) console.log(`[check-version] [前端] ${s.file.padEnd(28)} ${s.version || '(缺失)'}`)
|
||||||
console.log(`[check-version] ${s.file.padEnd(28)} ${s.version || '(缺失)'}`)
|
for (const s of shellSources) console.log(`[check-version] [外壳] ${s.file.padEnd(28)} ${s.version || '(缺失)'}`)
|
||||||
}
|
console.log('[check-version] 产物版本: OTA =', otaVersion, '/ 外壳(APK versionName) =', shellSources[0].version)
|
||||||
console.log('[check-version] 产物版本: APK =', version, '/ OTA =', otaVersion)
|
|
||||||
|
|
||||||
let failed = false
|
let failed = false
|
||||||
|
|
||||||
// ---- A. 四处一致性 + 裸版本号 ----
|
// ---- A1. 前端组: 裸版本号 ----
|
||||||
if (/-ota$/i.test(version)) {
|
if (/-ota$/i.test(webVersion)) {
|
||||||
console.error('')
|
console.error('')
|
||||||
console.error(`[check-version] package.json 的 version 不能带 "-ota": ${version}`)
|
console.error(`[check-version] package.json 的 version 不能带 "-ota": ${webVersion}`)
|
||||||
console.error('[check-version] OTA 后缀由 scripts/build-ota.mjs 自动追加, 请只写裸版本号 (如 0.2.0)')
|
console.error('[check-version] OTA 后缀由 scripts/build-ota.mjs 自动追加, 请只写裸版本号 (如 0.2.0)')
|
||||||
failed = true
|
failed = true
|
||||||
}
|
}
|
||||||
|
|
||||||
const mismatched = sources.filter((s) => s.version !== version)
|
// ---- A2. 外壳组内一致 ----
|
||||||
if (mismatched.length) {
|
const shellBroken = shellSources.filter((s) => s.version !== shellSources[0].version || !s.version)
|
||||||
|
if (shellBroken.length) {
|
||||||
console.error('')
|
console.error('')
|
||||||
console.error('============================================================')
|
console.error('============================================================')
|
||||||
console.error('[check-version] 版本号不一致, 构建中止')
|
console.error('[check-version] 外壳版本号不一致 (tauri.conf.json ↔ Cargo.toml ↔ Cargo.lock), 构建中止')
|
||||||
console.error('============================================================')
|
console.error('============================================================')
|
||||||
for (const s of mismatched) {
|
for (const s of shellBroken) {
|
||||||
console.error(` ${s.file}: ${s.version || '(缺失)'} ≠ package.json: ${version}`)
|
console.error(` ${s.file}: ${s.version || '(缺失)'} ≠ tauri.conf.json: ${shellSources[0].version}`)
|
||||||
}
|
}
|
||||||
console.error('')
|
|
||||||
console.error(' 规则: APK / 桌面包版本 = 裸版本号 (无后缀);')
|
|
||||||
console.error(' OTA 热更包版本 = 同一个版本号 + "-ota"。')
|
|
||||||
console.error(' 改版本时四处必须一起改 (见 readme「版本管理」)。')
|
|
||||||
failed = true
|
failed = true
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- B. 分支对应 ----
|
// ---- B. 分支对应 (仅前端组; 外壳在 android-* 分支独立演进, 不参与) ----
|
||||||
const m = /^dev-(\d+)(?:\.(\d+))?$/.exec(branch)
|
const m = /^dev-(\d+)(?:\.(\d+))?$/.exec(branch)
|
||||||
if (!m) {
|
if (!m) {
|
||||||
console.log('[check-version] 非 dev-<版本> 分支, 跳过分支对应校验')
|
console.log('[check-version] 非 dev-<版本> 分支, 跳过分支对应校验')
|
||||||
} else {
|
} else {
|
||||||
const want = m[2] === undefined ? `${m[1]}.` : `${m[1]}.${m[2]}`
|
const want = m[2] === undefined ? `${m[1]}.` : `${m[1]}.${m[2]}`
|
||||||
if (!version.startsWith(want)) {
|
if (!webVersion.startsWith(want)) {
|
||||||
console.error('')
|
console.error('')
|
||||||
console.error(`[check-version] 版本与分支不一致: 分支 "${branch}" 要求版本以 ${want} 开头, 实际是 ${version}`)
|
console.error(`[check-version] 版本与分支不一致: 分支 "${branch}" 要求前端版本以 ${want} 开头, 实际是 ${webVersion}`)
|
||||||
console.error('[check-version] 请修改 package.json 的 version, 并同步 tauri.conf.json / Cargo.toml / Cargo.lock')
|
console.error('[check-version] 请修改 package.json 的 version')
|
||||||
failed = true
|
failed = true
|
||||||
} else {
|
} else {
|
||||||
console.log(`[check-version] 分支一致 (${branch} ↔ ${version})`)
|
console.log(`[check-version] 分支一致 (${branch} ↔ ${webVersion})`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ function buildItem(habit, byDate, date, today) {
|
|||||||
id: habit.id,
|
id: habit.id,
|
||||||
name: habit.name,
|
name: habit.name,
|
||||||
period: habit.period,
|
period: habit.period,
|
||||||
|
sortOrder: habit.sortOrder,
|
||||||
periodLabel: PERIOD_LABEL[habit.period] || '每天',
|
periodLabel: PERIOD_LABEL[habit.period] || '每天',
|
||||||
target,
|
target,
|
||||||
color: habit.color,
|
color: habit.color,
|
||||||
@@ -141,8 +142,9 @@ router.post('/habits', async (req, res, next) => {
|
|||||||
if (!PERIODS.includes(period)) return res.status(400).json({ error: '周期类型不合法' })
|
if (!PERIODS.includes(period)) return res.status(400).json({ error: '周期类型不合法' })
|
||||||
const t = Math.floor(Number(target))
|
const t = Math.floor(Number(target))
|
||||||
if (!Number.isFinite(t) || t < 1) return res.status(400).json({ error: '目标次数需为 ≥1 的整数' })
|
if (!Number.isFinite(t) || t < 1) return res.status(400).json({ error: '目标次数需为 ≥1 的整数' })
|
||||||
|
const last = await prisma.habit.findFirst({ where: { userId }, orderBy: { sortOrder: 'desc' }, select: { sortOrder: true } })
|
||||||
const row = await prisma.habit.create({
|
const row = await prisma.habit.create({
|
||||||
data: { userId, name: cleanName, period, target: t, color: String(color), icon: String(icon), note: String(note || '') },
|
data: { userId, name: cleanName, period, target: t, color: String(color), icon: String(icon), note: String(note || ''), sortOrder: (last?.sortOrder ?? -1) + 1 },
|
||||||
})
|
})
|
||||||
res.json({ item: row })
|
res.json({ item: row })
|
||||||
} catch (e) { next(e) }
|
} catch (e) { next(e) }
|
||||||
@@ -182,6 +184,21 @@ router.put('/habits/:id', async (req, res, next) => {
|
|||||||
} catch (e) { next(e) }
|
} catch (e) { next(e) }
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// POST /api/checkin/habits/reorder { ids: [习惯id 的新顺序] } 按数组下标重写 sortOrder
|
||||||
|
router.post('/habits/reorder', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const userId = req.user.sub
|
||||||
|
const ids = Array.isArray((req.body || {}).ids) ? req.body.ids.map(Number).filter(Number.isFinite) : []
|
||||||
|
if (!ids.length) return res.status(400).json({ error: 'ids 不能为空' })
|
||||||
|
const habits = await prisma.habit.findMany({ where: { userId, id: { in: ids } } })
|
||||||
|
if (habits.length !== new Set(ids).size) return res.status(400).json({ error: '包含无效的打卡项' })
|
||||||
|
await prisma.$transaction(
|
||||||
|
ids.map((id, i) => prisma.habit.update({ where: { id }, data: { sortOrder: i } })),
|
||||||
|
)
|
||||||
|
res.json({ ok: true })
|
||||||
|
} catch (e) { next(e) }
|
||||||
|
})
|
||||||
|
|
||||||
// DELETE /api/checkin/habits/:id?hard=1 默认归档(保留历史), hard=1 物理删除
|
// DELETE /api/checkin/habits/:id?hard=1 默认归档(保留历史), hard=1 物理删除
|
||||||
router.delete('/habits/:id', async (req, res, next) => {
|
router.delete('/habits/:id', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ router.get('/ota/check/:current_sequence', (req, res) => {
|
|||||||
url: (base ? base : '') + '/api/ota/bundle/' + encodeURIComponent(latest.bundle),
|
url: (base ? base : '') + '/api/ota/bundle/' + encodeURIComponent(latest.bundle),
|
||||||
signature: latest.signature,
|
signature: latest.signature,
|
||||||
notes: latest.notes || '',
|
notes: latest.notes || '',
|
||||||
|
commit: latest.commit || '',
|
||||||
pub_date: latest.pub_date || new Date().toISOString(),
|
pub_date: latest.pub_date || new Date().toISOString(),
|
||||||
mandatory: !!latest.mandatory,
|
mandatory: !!latest.mandatory,
|
||||||
bundle_size: latest.bundle_size || 0,
|
bundle_size: latest.bundle_size || 0,
|
||||||
@@ -91,6 +92,8 @@ router.get('/ota/manifest-web', (_req, res) => {
|
|||||||
res.json({
|
res.json({
|
||||||
available: true,
|
available: true,
|
||||||
version: latest.version,
|
version: latest.version,
|
||||||
|
sequence: latest.sequence,
|
||||||
|
commit: latest.commit || '',
|
||||||
build_id: latest.build_id || latest.version,
|
build_id: latest.build_id || latest.version,
|
||||||
note: latest.notes || '',
|
note: latest.notes || '',
|
||||||
size: latest.bundle_size || 0,
|
size: latest.bundle_size || 0,
|
||||||
|
|||||||
@@ -38,6 +38,20 @@ export async function ensurePersonalProject(userId) {
|
|||||||
return project
|
return project
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 任务类型标签: 项目级自定义, 未配置时用默认四类
|
||||||
|
export const DEFAULT_TASK_KINDS = ['需求', 'Bug', 'FEATURE', '优化']
|
||||||
|
|
||||||
|
export function normTaskKinds(v) {
|
||||||
|
if (!Array.isArray(v)) return null
|
||||||
|
const out = []
|
||||||
|
for (const k of v) {
|
||||||
|
const s = String(k || '').trim().slice(0, 20)
|
||||||
|
if (s && !out.includes(s)) out.push(s)
|
||||||
|
if (out.length >= 30) break
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
function projectSummary(p) {
|
function projectSummary(p) {
|
||||||
return {
|
return {
|
||||||
id: p.id,
|
id: p.id,
|
||||||
@@ -47,6 +61,8 @@ function projectSummary(p) {
|
|||||||
archived: p.archived,
|
archived: p.archived,
|
||||||
createdAt: p.createdAt,
|
createdAt: p.createdAt,
|
||||||
updatedAt: p.updatedAt,
|
updatedAt: p.updatedAt,
|
||||||
|
taskKinds: normTaskKinds(p.taskKinds) || DEFAULT_TASK_KINDS,
|
||||||
|
icon: p.icon || null,
|
||||||
memberCount: p._count?.members ?? undefined,
|
memberCount: p._count?.members ?? undefined,
|
||||||
taskCount: p._count?.tasks ?? undefined,
|
taskCount: p._count?.tasks ?? undefined,
|
||||||
}
|
}
|
||||||
@@ -119,7 +135,7 @@ router.put('/:id', async (req, res, next) => {
|
|||||||
const userId = req.user.sub
|
const userId = req.user.sub
|
||||||
const project = await loadProjectForUser(req.params.id, userId)
|
const project = await loadProjectForUser(req.params.id, userId)
|
||||||
if (!project) return res.status(404).json({ error: '项目不存在或无权限' })
|
if (!project) return res.status(404).json({ error: '项目不存在或无权限' })
|
||||||
const { name, archived, notes } = req.body || {}
|
const { name, archived, notes, taskKinds, icon } = req.body || {}
|
||||||
const data = {}
|
const data = {}
|
||||||
if (name !== undefined) {
|
if (name !== undefined) {
|
||||||
const t = String(name).trim()
|
const t = String(name).trim()
|
||||||
@@ -127,6 +143,22 @@ router.put('/:id', async (req, res, next) => {
|
|||||||
if (t.length > MAX_NAME) return res.status(400).json({ error: `项目名称不能超过 ${MAX_NAME} 字` })
|
if (t.length > MAX_NAME) return res.status(400).json({ error: `项目名称不能超过 ${MAX_NAME} 字` })
|
||||||
data.name = t
|
data.name = t
|
||||||
}
|
}
|
||||||
|
// 任务类型标签: 整体替换; 空数组回落默认
|
||||||
|
if (taskKinds !== undefined) {
|
||||||
|
if (!Array.isArray(taskKinds)) return res.status(400).json({ error: '任务类型格式非法' })
|
||||||
|
const kinds = normTaskKinds(taskKinds)
|
||||||
|
if (!kinds?.length) return res.status(400).json({ error: '至少保留一个任务类型' })
|
||||||
|
data.taskKinds = kinds
|
||||||
|
}
|
||||||
|
// 项目图标: data URL (客户端已裁剪), null 清除回退首字色块
|
||||||
|
if (icon !== undefined && icon !== null) {
|
||||||
|
const s = String(icon)
|
||||||
|
if (!s.startsWith('data:image/')) return res.status(400).json({ error: '图标格式非法' })
|
||||||
|
if (s.length > 300_000) return res.status(400).json({ error: '图标过大' })
|
||||||
|
data.icon = s
|
||||||
|
} else if (icon === null) {
|
||||||
|
data.icon = null
|
||||||
|
}
|
||||||
if (archived !== undefined) data.archived = !!archived
|
if (archived !== undefined) data.archived = !!archived
|
||||||
// 项目信息块 (T-PJ-13): 任意多个 { title, content }, 整体替换
|
// 项目信息块 (T-PJ-13): 任意多个 { title, content }, 整体替换
|
||||||
if (notes !== undefined) {
|
if (notes !== undefined) {
|
||||||
|
|||||||
+46
-3
@@ -70,10 +70,11 @@ router.post('/projects/:id/tasks', async (req, res, next) => {
|
|||||||
const project = await loadProjectForUser(req.params.id, userId)
|
const project = await loadProjectForUser(req.params.id, userId)
|
||||||
if (!project) return res.status(404).json({ error: '项目不存在或无权限' })
|
if (!project) return res.status(404).json({ error: '项目不存在或无权限' })
|
||||||
|
|
||||||
const { title, description = '', parentId, assigneeId, memberIds = [], tags = [] } = req.body || {}
|
const { title, description = '', parentId, assigneeId, memberIds = [], tags = [], kind, markDate } = req.body || {}
|
||||||
const cleanTitle = String(title || '').trim()
|
const cleanTitle = String(title || '').trim()
|
||||||
if (!cleanTitle) return res.status(400).json({ error: '任务标题不能为空' })
|
if (!cleanTitle) return res.status(400).json({ error: '任务标题不能为空' })
|
||||||
if (cleanTitle.length > 200) return res.status(400).json({ error: '任务标题不能超过 200 字' })
|
if (cleanTitle.length > 30) return res.status(400).json({ error: '任务标题不能超过 30 字' })
|
||||||
|
const mark = parseMarkDate(markDate)
|
||||||
|
|
||||||
const columns = await loadColumns(project.id)
|
const columns = await loadColumns(project.id)
|
||||||
if (!columns.length) return res.status(400).json({ error: '项目没有可用的看板列' })
|
if (!columns.length) return res.status(400).json({ error: '项目没有可用的看板列' })
|
||||||
@@ -107,6 +108,8 @@ router.post('/projects/:id/tasks', async (req, res, next) => {
|
|||||||
parentId: pid,
|
parentId: pid,
|
||||||
title: cleanTitle,
|
title: cleanTitle,
|
||||||
description: String(description || ''),
|
description: String(description || ''),
|
||||||
|
kind: kind ? String(kind).trim().slice(0, 20) : null,
|
||||||
|
markDate: mark,
|
||||||
status: firstCol.boundStatus || 'todo',
|
status: firstCol.boundStatus || 'todo',
|
||||||
assigneeId: finalAssignee,
|
assigneeId: finalAssignee,
|
||||||
creatorId: userId,
|
creatorId: userId,
|
||||||
@@ -124,6 +127,27 @@ router.post('/projects/:id/tasks', async (req, res, next) => {
|
|||||||
} catch (e) { next(e) }
|
} catch (e) { next(e) }
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// POST /api/projects/:id/tasks/reorder { ids: [顶层任务 id 的新顺序] }
|
||||||
|
// 按数组下标重写 sortOrder (组内拖动排序; 子任务不参与, 跟随父任务)
|
||||||
|
router.post('/projects/:id/tasks/reorder', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const userId = req.user.sub
|
||||||
|
const project = await loadProjectForUser(req.params.id, userId)
|
||||||
|
if (!project) return res.status(404).json({ error: '项目不存在或无权限' })
|
||||||
|
const ids = Array.isArray((req.body || {}).ids) ? req.body.ids.map(Number).filter(Number.isFinite) : []
|
||||||
|
if (!ids.length) return res.status(400).json({ error: 'ids 不能为空' })
|
||||||
|
const rows = await prisma.task.findMany({
|
||||||
|
where: { projectId: project.id, id: { in: ids }, parentId: null },
|
||||||
|
select: { id: true },
|
||||||
|
})
|
||||||
|
if (rows.length !== new Set(ids).size) return res.status(400).json({ error: '包含无效的任务' })
|
||||||
|
await prisma.$transaction(
|
||||||
|
ids.map((id, i) => prisma.task.update({ where: { id }, data: { sortOrder: i } })),
|
||||||
|
)
|
||||||
|
res.json({ ok: true })
|
||||||
|
} catch (e) { next(e) }
|
||||||
|
})
|
||||||
|
|
||||||
// ---------- 单条任务 ----------
|
// ---------- 单条任务 ----------
|
||||||
|
|
||||||
// GET /api/tasks/:id
|
// GET /api/tasks/:id
|
||||||
@@ -136,6 +160,14 @@ router.get('/tasks/:id', async (req, res, next) => {
|
|||||||
} catch (e) { next(e) }
|
} catch (e) { next(e) }
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 时间标记: 接受 YYYY-MM-DD (或完整 ISO), 存 DateTime; 非法输入 400 由调用方处理
|
||||||
|
function parseMarkDate(v) {
|
||||||
|
if (v === null || v === undefined || v === '') return null
|
||||||
|
const d = new Date(v)
|
||||||
|
if (Number.isNaN(d.getTime())) return undefined // 非法
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
// PUT /api/tasks/:id 关键: 列/状态联动裁决
|
// PUT /api/tasks/:id 关键: 列/状态联动裁决
|
||||||
router.put('/tasks/:id', async (req, res, next) => {
|
router.put('/tasks/:id', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
@@ -152,10 +184,21 @@ router.put('/tasks/:id', async (req, res, next) => {
|
|||||||
if (body.title !== undefined) {
|
if (body.title !== undefined) {
|
||||||
const t = String(body.title).trim()
|
const t = String(body.title).trim()
|
||||||
if (!t) return res.status(400).json({ error: '任务标题不能为空' })
|
if (!t) return res.status(400).json({ error: '任务标题不能为空' })
|
||||||
if (t.length > 200) return res.status(400).json({ error: '任务标题不能超过 200 字' })
|
if (t.length > 30) return res.status(400).json({ error: '任务标题不能超过 30 字' })
|
||||||
data.title = t
|
data.title = t
|
||||||
}
|
}
|
||||||
if (body.description !== undefined) data.description = String(body.description)
|
if (body.description !== undefined) data.description = String(body.description)
|
||||||
|
// 任务类型标签: null 清空, 否则截断保存
|
||||||
|
if (body.kind !== undefined) {
|
||||||
|
const k = String(body.kind || '').trim().slice(0, 20)
|
||||||
|
data.kind = k || null
|
||||||
|
}
|
||||||
|
// 时间标记: 完整日期存储
|
||||||
|
if (body.markDate !== undefined) {
|
||||||
|
const mark = parseMarkDate(body.markDate)
|
||||||
|
if (mark === undefined) return res.status(400).json({ error: '时间标记格式非法' })
|
||||||
|
data.markDate = mark
|
||||||
|
}
|
||||||
if (body.extras !== undefined) {
|
if (body.extras !== undefined) {
|
||||||
if (!Array.isArray(body.extras)) return res.status(400).json({ error: '扩展信息格式非法' })
|
if (!Array.isArray(body.extras)) return res.status(400).json({ error: '扩展信息格式非法' })
|
||||||
data.extras = body.extras.map((e) => ({
|
data.extras = body.extras.map((e) => ({
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import MePage from './pages/MePage.jsx'
|
|||||||
import CheckinPage from './pages/checkin/CheckinPage.jsx'
|
import CheckinPage from './pages/checkin/CheckinPage.jsx'
|
||||||
import TaskPage from './pages/task/TaskPage.jsx'
|
import TaskPage from './pages/task/TaskPage.jsx'
|
||||||
import { useAuth } from './auth/AuthContext.jsx'
|
import { useAuth } from './auth/AuthContext.jsx'
|
||||||
import useKeyboardInsetWatcher from './hooks/useKeyboardInsetWatcher.js'
|
|
||||||
|
|
||||||
// 默认落地页: 打卡 (V0.2 起打卡是第一个 Tab 且为默认选中)
|
// 默认落地页: 打卡 (V0.2 起打卡是第一个 Tab 且为默认选中)
|
||||||
const HOME_PATH = '/checkin'
|
const HOME_PATH = '/checkin'
|
||||||
@@ -31,8 +30,6 @@ function RequireAuth({ children }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
// 键盘插入量全局监听: 写入 --kb-inset, 弹层用 .kb-safe 类即可避让键盘 (重点设计 §7.2)
|
|
||||||
useKeyboardInsetWatcher()
|
|
||||||
return (
|
return (
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/login" element={<RedirectIfAuthed><LoginPage /></RedirectIfAuthed>} />
|
<Route path="/login" element={<RedirectIfAuthed><LoginPage /></RedirectIfAuthed>} />
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export const checkinApi = {
|
|||||||
calendar: (month) => api('/checkin/calendar' + q({ month })),
|
calendar: (month) => api('/checkin/calendar' + q({ month })),
|
||||||
createHabit: (body) => api('/checkin/habits', { method: 'POST', body }),
|
createHabit: (body) => api('/checkin/habits', { method: 'POST', body }),
|
||||||
updateHabit: (id, body) => api(`/checkin/habits/${id}`, { method: 'PUT', body }),
|
updateHabit: (id, body) => api(`/checkin/habits/${id}`, { method: 'PUT', body }),
|
||||||
|
reorderHabits: (ids) => api('/checkin/habits/reorder', { method: 'POST', body: { ids } }),
|
||||||
deleteHabit: (id, hard) => api(`/checkin/habits/${id}${hard ? '?hard=1' : ''}`, { method: 'DELETE' }),
|
deleteHabit: (id, hard) => api(`/checkin/habits/${id}${hard ? '?hard=1' : ''}`, { method: 'DELETE' }),
|
||||||
check: (id, date, note) => api(`/checkin/habits/${id}/check`, { method: 'POST', body: { date, note } }),
|
check: (id, date, note) => api(`/checkin/habits/${id}/check`, { method: 'POST', body: { date, note } }),
|
||||||
uncheck: (id, date) => api(`/checkin/habits/${id}/check` + q({ date }), { method: 'DELETE' }),
|
uncheck: (id, date) => api(`/checkin/habits/${id}/check` + q({ date }), { method: 'DELETE' }),
|
||||||
|
|||||||
@@ -22,6 +22,19 @@ export function getCurrentVersionInfo() {
|
|||||||
return { version: APP_VERSION, build: BUILD_ID }
|
return { version: APP_VERSION, build: BUILD_ID }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 构建标识统一化: 任意来源 (YYYYMMDDHHmmss / seq-<ms> / 毫秒数) → 'YYYYMMDDHHmmss'
|
||||||
|
// (北京时间, 与 vite BUILD_ID / OTA manifest.build_id 同格式; 无法解析返回 '')
|
||||||
|
export function fmtBuildId(v) {
|
||||||
|
if (v == null) return ''
|
||||||
|
if (typeof v === 'string' && v.startsWith('seq-')) return fmtBuildId(Number(v.slice(4)))
|
||||||
|
if (typeof v === 'string' && /^\d{14}$/.test(v)) return v
|
||||||
|
const ms = Number(v)
|
||||||
|
if (!Number.isFinite(ms) || ms <= 0) return ''
|
||||||
|
const d = new Date(ms + 8 * 3600 * 1000)
|
||||||
|
const p = (n, w) => String(n).padStart(w || 2, '0')
|
||||||
|
return `${d.getUTCFullYear()}${p(d.getUTCMonth() + 1)}${p(d.getUTCDate())}${p(d.getUTCHours())}${p(d.getUTCMinutes())}${p(d.getUTCSeconds())}`
|
||||||
|
}
|
||||||
|
|
||||||
// 启动时: 确认当前资源可用 (Tauri only)
|
// 启动时: 确认当前资源可用 (Tauri only)
|
||||||
// 失败/异常都不抛, 仅 console.warn
|
// 失败/异常都不抛, 仅 console.warn
|
||||||
export function initOta() {
|
export function initOta() {
|
||||||
@@ -114,7 +127,9 @@ async function checkByVersion() {
|
|||||||
info: {
|
info: {
|
||||||
version: manifest.version,
|
version: manifest.version,
|
||||||
sequence: manifest.sequence,
|
sequence: manifest.sequence,
|
||||||
|
build: manifest.build_id || fmtBuildId(manifest.sequence),
|
||||||
notes: manifest.notes || '',
|
notes: manifest.notes || '',
|
||||||
|
commit: manifest.commit || '',
|
||||||
mandatory: !!manifest.mandatory,
|
mandatory: !!manifest.mandatory,
|
||||||
bundle_size: manifest.bundle_size || 0,
|
bundle_size: manifest.bundle_size || 0,
|
||||||
},
|
},
|
||||||
@@ -135,6 +150,7 @@ async function checkOtaUpdateTauri(devMode) {
|
|||||||
info: {
|
info: {
|
||||||
version: r.version,
|
version: r.version,
|
||||||
sequence: r.sequence,
|
sequence: r.sequence,
|
||||||
|
build: fmtBuildId(r.sequence),
|
||||||
notes: r.notes,
|
notes: r.notes,
|
||||||
mandatory: r.mandatory,
|
mandatory: r.mandatory,
|
||||||
bundle_size: r.bundle_size,
|
bundle_size: r.bundle_size,
|
||||||
@@ -166,7 +182,10 @@ async function checkOtaUpdateWeb(devMode) {
|
|||||||
reason: 'ok',
|
reason: 'ok',
|
||||||
info: {
|
info: {
|
||||||
version: data.version,
|
version: data.version,
|
||||||
|
sequence: data.sequence,
|
||||||
|
build: fmtBuildId(data.build_id || data.sequence),
|
||||||
notes: data.note || '',
|
notes: data.note || '',
|
||||||
|
commit: data.commit || '',
|
||||||
// web 端没有强制/mandatory 等字段, 仅展示
|
// web 端没有强制/mandatory 等字段, 仅展示
|
||||||
mandatory: false,
|
mandatory: false,
|
||||||
bundle_size: data.size || 0,
|
bundle_size: data.size || 0,
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ export const taskApi = {
|
|||||||
create: (projectId, body) => api(`/projects/${projectId}/tasks`, { method: 'POST', body }),
|
create: (projectId, body) => api(`/projects/${projectId}/tasks`, { method: 'POST', body }),
|
||||||
detail: (id) => api(`/tasks/${id}`),
|
detail: (id) => api(`/tasks/${id}`),
|
||||||
update: (id, body) => api(`/tasks/${id}`, { method: 'PUT', body }),
|
update: (id, body) => api(`/tasks/${id}`, { method: 'PUT', body }),
|
||||||
|
reorder: (projectId, ids) => api(`/projects/${projectId}/tasks/reorder`, { method: 'POST', body: { ids } }),
|
||||||
remove: (id) => api(`/tasks/${id}`, { method: 'DELETE' }),
|
remove: (id) => api(`/tasks/${id}`, { method: 'DELETE' }),
|
||||||
copy: (id, projectId) => api(`/tasks/${id}/copy`, { method: 'POST', body: { projectId } }),
|
copy: (id, projectId) => api(`/tasks/${id}/copy`, { method: 'POST', body: { projectId } }),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
<Popup
|
||||||
visible={visible}
|
visible={visible}
|
||||||
onMaskClick={onClose}
|
onMaskClick={onClose}
|
||||||
className="kb-safe"
|
bodyStyle={{
|
||||||
|
borderTopLeftRadius: 12, borderTopRightRadius: 12,
|
||||||
|
padding: '16px 16px calc(env(safe-area-inset-bottom, 0px) + 20px)',
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div className="sheet-title">跨项目拷贝</div>
|
<div className="sheet-title">跨项目拷贝</div>
|
||||||
<div className="sheet-sub">「{task.title}」→ 选择目标项目;源任务保持不变</div>
|
<div className="sheet-sub">「{task.title}」→ 选择目标项目;源任务保持不变</div>
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { getRecentIds, getRecentOrder, setRecentOrder } from '../../pages/task/prefs.js'
|
import { getRecentIds, getRecentOrder, setRecentOrder } from '../../pages/task/prefs.js'
|
||||||
|
import { colorOfProject } from '../../pages/task/projectIcon.js'
|
||||||
|
|
||||||
// 首屏最近项目条 (T-VW-08/10)
|
// 首屏最近项目条 (T-VW-08/10)
|
||||||
// - 展示最近使用的 3 个项目 + 「更多项目」入口
|
// - 展示最近使用的 3 个项目 + 「更多项目」入口
|
||||||
// - 顺序: 手动排序(recentOrder)优先, 其次最近访问(recentIds), 兜底列表顺序
|
// - 顺序: 手动排序(recentOrder)优先, 其次最近访问(recentIds), 兜底列表顺序
|
||||||
// - 长按卡片 300ms 进入拖拽态, 水平拖动实时换位, 松手持久化(设备端)
|
// - 长按卡片 300ms 进入拖拽态, 水平拖动实时换位, 松手持久化(设备端)
|
||||||
const COLORS = ['#1677ff', '#00b578', '#ff8f1f', '#a855f7', '#14b8a6', '#f43f9e']
|
|
||||||
const colorOf = (id) => COLORS[Math.abs(id) % COLORS.length]
|
|
||||||
const LONG_PRESS = 300
|
const LONG_PRESS = 300
|
||||||
const MOVE_TOLERANCE = 8
|
const MOVE_TOLERANCE = 8
|
||||||
|
|
||||||
@@ -123,8 +122,13 @@ export default function RecentProjects({ projects, currentId, onSelect, onMore }
|
|||||||
onMouseDown={(ev) => beginPress(i, ev)}
|
onMouseDown={(ev) => beginPress(i, ev)}
|
||||||
onClick={() => { if (!active.current && dragIdx < 0) onSelect?.(p.id) }}
|
onClick={() => { if (!active.current && dragIdx < 0) onSelect?.(p.id) }}
|
||||||
>
|
>
|
||||||
<div className="rp-ico" style={{ background: colorOf(p.id) }}>
|
<div
|
||||||
{(p.name || '?').slice(0, 1)}
|
className="rp-ico"
|
||||||
|
style={p.icon
|
||||||
|
? { background: `url(${p.icon}) center/cover no-repeat` }
|
||||||
|
: { background: colorOfProject(p) }}
|
||||||
|
>
|
||||||
|
{p.icon ? '' : (p.name || '?').slice(0, 1)}
|
||||||
</div>
|
</div>
|
||||||
<div className="rp-name">{p.name}</div>
|
<div className="rp-name">{p.name}</div>
|
||||||
<div className="rp-meta">{p.taskCount ?? 0} 个任务</div>
|
<div className="rp-meta">{p.taskCount ?? 0} 个任务</div>
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import StatusDot from './StatusDot.jsx'
|
import StatusDot from './StatusDot.jsx'
|
||||||
|
import Avatar from './Avatar.jsx'
|
||||||
|
import { kindColor } from '../../pages/task/kind.js'
|
||||||
|
|
||||||
// 看板卡片 (T-UI-02): 轻量化
|
// 看板卡片 (T-UI-02): 轻量化
|
||||||
// - 标题 + 状态点 (T-ST-09)
|
// - 标题 + 状态点 (T-ST-09)
|
||||||
@@ -20,37 +22,51 @@ export default function TaskCard({
|
|||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
{...(bindDrag ? bindDrag(task) : {})}
|
{...(bindDrag ? bindDrag(task) : {})}
|
||||||
>
|
>
|
||||||
<div className="brow1">
|
{/* 左侧竖排: 状态点 + ⋯ */}
|
||||||
<span className="btitle">{task.title}</span>
|
<div className="bcard-left">
|
||||||
<span
|
<span
|
||||||
className="bstat"
|
className="bstat"
|
||||||
onClick={(e) => { e.stopPropagation(); onToggle?.(task) }}
|
onClick={(e) => { e.stopPropagation(); onToggle?.(task) }}
|
||||||
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
title="点按切换完成"
|
title="点按切换完成"
|
||||||
>
|
>
|
||||||
<StatusDot status={task.status} />
|
<StatusDot status={task.status} />
|
||||||
</span>
|
</span>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bmeta">
|
|
||||||
<span className={`bassignee${task.assignee ? '' : ' none'}`}>
|
|
||||||
{task.assignee?.name || '未指派'}
|
|
||||||
</span>
|
|
||||||
{task.members?.length > 0 && (
|
|
||||||
<span className="bmembers">+ {task.members.map((m) => m.name).join('、')}</span>
|
|
||||||
)}
|
|
||||||
{onMore && (
|
{onMore && (
|
||||||
<span
|
<span
|
||||||
className="bmore-btn"
|
className="bmore-btn"
|
||||||
onClick={(e) => { e.stopPropagation(); onMore(task) }}
|
onClick={(e) => { e.stopPropagation(); onMore(task) }}
|
||||||
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
>⋯</span>
|
>⋯</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="bcard-body">
|
||||||
|
<div className="brow1">
|
||||||
|
<span className="btitle">{task.title}</span>
|
||||||
|
</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) => (
|
{shown.map((c) => (
|
||||||
<div
|
<div
|
||||||
key={c.id}
|
key={c.id}
|
||||||
className={`bsub${c.status === 'done' ? ' done' : ''}`}
|
className={`bsub${c.status === 'done' ? ' done' : ''}`}
|
||||||
onClick={(e) => { e.stopPropagation(); onToggleSub?.(c) }}
|
onClick={(e) => { e.stopPropagation(); onToggleSub?.(c) }}
|
||||||
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<span className="sbox" />
|
<span className="sbox" />
|
||||||
<span className="stitle">{c.title}</span>
|
<span className="stitle">{c.title}</span>
|
||||||
@@ -59,5 +75,6 @@ export default function TaskCard({
|
|||||||
))}
|
))}
|
||||||
{rest > 0 && <div className="bmore">+{rest} 个子任务</div>}
|
{rest > 0 && <div className="bmore">+{rest} 个子任务</div>}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,49 +1,37 @@
|
|||||||
import { useEffect, useRef } from 'react'
|
|
||||||
import StatusDot from './StatusDot.jsx'
|
import StatusDot from './StatusDot.jsx'
|
||||||
|
import Avatar from './Avatar.jsx'
|
||||||
|
import { kindColor } from '../../pages/task/kind.js'
|
||||||
|
|
||||||
|
const statusLabel = (s) => (s === 'done' ? '完成' : s === 'doing' ? '执行中' : '未开始')
|
||||||
|
|
||||||
// 列表视图的一行 (Todoist 风格, T-UI-01)
|
// 列表视图的一行 (Todoist 风格, T-UI-01)
|
||||||
// 长按 500ms → onLongPress (移动到列菜单); 点击其他区域 → onClick (编辑)
|
// 点击行体 → 打开详情; 长按 400ms 后左右拖动换列 (bindDrag 来自 useRowDrag)
|
||||||
const LONG_PRESS = 500
|
// 子任务与普通任务同构: 标题一行 + 状态元信息一行 (字体略小), 不显示分组标签
|
||||||
|
// 复选框 / ⋯ 是独立控件: pointerdown 不冒泡, 不发起拖动,
|
||||||
|
// 避免长按行后抬手时合成的 click 落在控件上误触状态切换
|
||||||
export default function TaskRow({
|
export default function TaskRow({
|
||||||
task, onToggle, onClick, onMore, onLongPress, indent = false, children,
|
task, onToggle, onClick, onMore, bindDrag, bindDragHandle, dragging = false, dragOver, indent = false, children,
|
||||||
}) {
|
}) {
|
||||||
const timer = useRef(null)
|
|
||||||
const moved = useRef(false)
|
|
||||||
|
|
||||||
const clear = () => { if (timer.current) { clearTimeout(timer.current); timer.current = null } }
|
|
||||||
useEffect(() => () => clear(), [])
|
|
||||||
|
|
||||||
const startPress = () => {
|
|
||||||
moved.current = false
|
|
||||||
clear()
|
|
||||||
if (!onLongPress) return
|
|
||||||
timer.current = setTimeout(() => {
|
|
||||||
if (!moved.current) {
|
|
||||||
if (navigator.vibrate) navigator.vibrate(15)
|
|
||||||
onLongPress(task)
|
|
||||||
}
|
|
||||||
}, LONG_PRESS)
|
|
||||||
}
|
|
||||||
const cancelPress = () => clear()
|
|
||||||
const markMoved = () => { moved.current = true; clear() }
|
|
||||||
|
|
||||||
const done = task.status === 'done'
|
const done = task.status === 'done'
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
className={`${indent ? 'sub-row' : 'task-row'}${done ? ' done' : ''}`}
|
className={
|
||||||
|
`${indent ? 'sub-row' : 'task-row'}${done ? ' done' : ''}`
|
||||||
|
+ (dragging ? ' dragging' : '')
|
||||||
|
+ (dragOver ? ` dragover ${dragOver.after ? 'after' : 'before'}` : '')
|
||||||
|
}
|
||||||
data-task-id={indent ? undefined : task.id}
|
data-task-id={indent ? undefined : task.id}
|
||||||
onClick={() => { if (!moved.current) onClick?.(task) }}
|
onClick={onClick}
|
||||||
onTouchStart={startPress}
|
{...(!indent && bindDrag ? bindDrag(task) : {})}
|
||||||
onTouchMove={markMoved}
|
|
||||||
onTouchEnd={cancelPress}
|
|
||||||
onMouseDown={startPress}
|
|
||||||
onMouseUp={cancelPress}
|
|
||||||
>
|
>
|
||||||
|
{!indent && bindDragHandle && (
|
||||||
|
<span className="grip" title="拖动排序 / 换列" {...bindDragHandle(task)}>⠿</span>
|
||||||
|
)}
|
||||||
<div
|
<div
|
||||||
className="box"
|
className="box"
|
||||||
onClick={(e) => { e.stopPropagation(); onToggle?.(task) }}
|
onClick={(e) => { e.stopPropagation(); onToggle?.(task) }}
|
||||||
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<svg viewBox="0 0 14 14" width={indent ? 10 : 11} height={indent ? 10 : 11}>
|
<svg viewBox="0 0 14 14" width={indent ? 10 : 11} height={indent ? 10 : 11}>
|
||||||
<path d="M2.5 7.2l3 3 6-6.4" stroke="#fff" strokeWidth="2" fill="none" strokeLinecap="round" />
|
<path d="M2.5 7.2l3 3 6-6.4" stroke="#fff" strokeWidth="2" fill="none" strokeLinecap="round" />
|
||||||
@@ -51,28 +39,37 @@ export default function TaskRow({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{indent ? (
|
{indent ? (
|
||||||
<>
|
<div className="sub-main">
|
||||||
<span className="sub-title">{task.title}</span>
|
<span className="sub-title">{task.title}</span>
|
||||||
<StatusDot status={task.status} pill />
|
<div className="task-meta sub">
|
||||||
</>
|
<StatusDot status={task.status} />
|
||||||
|
<span>{statusLabel(task.status)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="task-main">
|
<div className="task-main">
|
||||||
<div className="task-title">{task.title}</div>
|
<div className="task-title">{task.title}</div>
|
||||||
|
{/* 无类型/成员也保留该行, 与有标签的行等高 */}
|
||||||
<div className="task-meta">
|
<div className="task-meta">
|
||||||
<StatusDot status={task.status} />
|
{task.markDate && (
|
||||||
<span>{task.status === 'done' ? '完成' : task.status === 'doing' ? '执行中' : '未开始'}</span>
|
<span className="date-tag">{task.markDate.slice(5, 10).replace('-', '/')}</span>
|
||||||
{task.column?.name && <span className="col">{task.column.name}</span>}
|
)}
|
||||||
<span className={`assignee${task.assignee ? '' : ' none'}`}>
|
{task.kind && (
|
||||||
{task.assignee?.name || '未指派'}
|
<span className="kind-tag" style={{ background: kindColor(task.kind) }}>{task.kind}</span>
|
||||||
</span>
|
)}
|
||||||
{task.members?.length > 0 && (
|
{task.members?.length > 0 && (
|
||||||
<span className="members">+ {task.members.map((m) => m.name).join('、')}</span>
|
<span className="members">+ {task.members.map((m) => m.name).join('、')}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="task-right">
|
<div className="task-right">
|
||||||
<span className="more" onClick={(e) => { e.stopPropagation(); onMore?.(task) }}>⋯</span>
|
<span
|
||||||
|
className="more"
|
||||||
|
onClick={(e) => { e.stopPropagation(); onMore?.(task) }}
|
||||||
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
|
>⋯</span>
|
||||||
|
<Avatar name={task.assignee?.name} size={20} />
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ export default function UserPicker({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Popup visible={visible} onMaskClick={onClose} position="bottom" className="kb-safe">
|
<Popup visible={visible} onMaskClick={onClose} bodyStyle={{ borderTopLeftRadius: 12, borderTopRightRadius: 12, padding: '16px 16px calc(env(safe-area-inset-bottom, 0px) + 20px)' }}>
|
||||||
<div className="sheet-title">{title}</div>
|
<div className="sheet-title">{title}</div>
|
||||||
<div className="sheet-sub">仅可选择当前项目的成员</div>
|
<div className="sheet-sub">仅可选择当前项目的成员</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,18 +1,44 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
|
||||||
// 看板卡片列拖动 (重点设计 §7.1, 2026-10-24 重做)
|
// 看板卡片列拖动 (重点设计 §7.1)
|
||||||
// 方案要点 (替代旧版"手指跟随浮层 + 落点即子任务"的不可靠实现):
|
// 方案要点:
|
||||||
// 1. Pointer Events + setPointerCapture —— 触摸/鼠标统一, 捕获后不丢事件
|
// 1. Pointer Events + setPointerCapture —— 触摸/鼠标统一, 捕获后不丢事件
|
||||||
// 2. 长按 400ms 激活 (先给滚动让路: 激活前移动 >10px 视为滚动并取消)
|
// 2. 长按 400ms 激活 (激活前移动 >10px 视为滚动, 取消本次拖动)
|
||||||
// 3. 激活后卡片原位抬起 (不跟随手指, 无浮层/无任务名), touch-action 由捕获保证
|
// 3. 激活后卡片原位抬起 (不跟随手指, 无浮层/无任务名)
|
||||||
// 4. 拖动中持续 elementFromPoint 命中 [data-column-id] 高亮目标列;
|
// 4. 拖动中持续 elementFromPoint 命中 [data-column-id] 高亮目标列;
|
||||||
// 指针靠近容器左右边缘时自动横向滚动, 可拖到屏幕外的列
|
// 指针靠近容器左右边缘时 rAF 自动横滚, 可拖到屏幕外的列
|
||||||
// 5. 松手/取消(pointercancel 也要处理) → 落到目标列(≠原列)才提交;
|
// 5. 松手落到目标列 (≠原列) 才提交; pointercancel (浏览器接管手势,
|
||||||
// 拖到卡片上不再转子任务 —— 统一走详情页「转为子任务」
|
// 如触发纵向滚动) 只放弃不提交
|
||||||
|
// 6. 防误触: 卡片禁长按系统菜单(contextmenu); 拖动结束后 350ms 内的
|
||||||
|
// click 视为拖动余波, 不打开详情 (justEnded)
|
||||||
export default function useColumnDrag({ onDrop, scrollSelector = '.board-wrap' } = {}) {
|
export default function useColumnDrag({ onDrop, scrollSelector = '.board-wrap' } = {}) {
|
||||||
const [dragTask, setDragTask] = useState(null) // 拖动中的任务 (视觉态)
|
const [dragTask, setDragTask] = useState(null) // 拖动中的任务 (视觉态)
|
||||||
const [overCol, setOverCol] = useState(null) // 高亮的目标列 id
|
const [overCol, setOverCol] = useState(null) // 高亮的目标列 id
|
||||||
const st = useRef({ timer: null, active: false, startX: 0, startY: 0, task: null, autoScroll: 0, raf: 0 })
|
const st = useRef({ timer: null, active: false, startX: 0, startY: 0, task: null, autoScroll: 0, raf: 0, srcEl: null, ghost: null })
|
||||||
|
const lastEnd = useRef(0) // 拖动结束时刻
|
||||||
|
|
||||||
|
// onDrop 经 ref 桥接实时闭包 (bind 空依赖 useCallback, 防陈旧)
|
||||||
|
const onDropRef = useRef(onDrop)
|
||||||
|
onDropRef.current = onDrop
|
||||||
|
|
||||||
|
// 跟手浮层: 激活时克隆原卡片挂到 body, 移动时直接改 transform
|
||||||
|
const moveGhost = (x, y) => {
|
||||||
|
const g = st.current.ghost
|
||||||
|
if (g) g.style.transform = `translate(${x}px, ${y}px) translate(-50%, -60%)`
|
||||||
|
}
|
||||||
|
const mountGhost = (x, y) => {
|
||||||
|
const src = st.current.srcEl
|
||||||
|
if (!src) return
|
||||||
|
const clone = src.cloneNode(true)
|
||||||
|
clone.classList.remove('dragging', 'hl')
|
||||||
|
clone.style.width = `${src.offsetWidth}px`
|
||||||
|
const g = document.createElement('div')
|
||||||
|
g.className = 'drag-ghost'
|
||||||
|
g.appendChild(clone)
|
||||||
|
document.body.appendChild(g)
|
||||||
|
st.current.ghost = g
|
||||||
|
moveGhost(x, y)
|
||||||
|
}
|
||||||
|
|
||||||
const stopAutoScroll = () => {
|
const stopAutoScroll = () => {
|
||||||
st.current.autoScroll = 0
|
st.current.autoScroll = 0
|
||||||
@@ -22,6 +48,8 @@ export default function useColumnDrag({ onDrop, scrollSelector = '.board-wrap' }
|
|||||||
const cleanup = useCallback(() => {
|
const cleanup = useCallback(() => {
|
||||||
if (st.current.timer) { clearTimeout(st.current.timer); st.current.timer = null }
|
if (st.current.timer) { clearTimeout(st.current.timer); st.current.timer = null }
|
||||||
st.current.active = false
|
st.current.active = false
|
||||||
|
st.current.srcEl = null
|
||||||
|
if (st.current.ghost) { st.current.ghost.remove(); st.current.ghost = null }
|
||||||
stopAutoScroll()
|
stopAutoScroll()
|
||||||
setDragTask(null)
|
setDragTask(null)
|
||||||
setOverCol(null)
|
setOverCol(null)
|
||||||
@@ -29,6 +57,9 @@ export default function useColumnDrag({ onDrop, scrollSelector = '.board-wrap' }
|
|||||||
|
|
||||||
useEffect(() => () => cleanup(), [cleanup])
|
useEffect(() => () => cleanup(), [cleanup])
|
||||||
|
|
||||||
|
/** 拖动刚结束 (350ms 内): 抑制松手合成的 click 误开详情页 */
|
||||||
|
const justEnded = useCallback(() => Date.now() - lastEnd.current < 350, [])
|
||||||
|
|
||||||
const hitColumn = (x, y) => {
|
const hitColumn = (x, y) => {
|
||||||
const el = document.elementFromPoint(x, y)
|
const el = document.elementFromPoint(x, y)
|
||||||
const colEl = el?.closest?.('[data-column-id]')
|
const colEl = el?.closest?.('[data-column-id]')
|
||||||
@@ -54,17 +85,6 @@ export default function useColumnDrag({ onDrop, scrollSelector = '.board-wrap' }
|
|||||||
st.current.raf = requestAnimationFrame(step)
|
st.current.raf = requestAnimationFrame(step)
|
||||||
}
|
}
|
||||||
|
|
||||||
const onPointerDown = (task) => (ev) => {
|
|
||||||
if (ev.button !== undefined && ev.button !== 0) return
|
|
||||||
st.current = { ...st.current, startX: ev.clientX, startY: ev.clientY, task, active: false }
|
|
||||||
// 长按计时: 期间移动超容差(用户在滚动)则取消
|
|
||||||
st.current.timer = setTimeout(() => {
|
|
||||||
st.current.active = true
|
|
||||||
setDragTask(task)
|
|
||||||
if (navigator.vibrate) navigator.vibrate(15)
|
|
||||||
}, 400)
|
|
||||||
}
|
|
||||||
|
|
||||||
const onPointerMove = (ev) => {
|
const onPointerMove = (ev) => {
|
||||||
const s = st.current
|
const s = st.current
|
||||||
if (!s.active) {
|
if (!s.active) {
|
||||||
@@ -74,6 +94,7 @@ export default function useColumnDrag({ onDrop, scrollSelector = '.board-wrap' }
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
ev.preventDefault()
|
ev.preventDefault()
|
||||||
|
moveGhost(ev.clientX, ev.clientY)
|
||||||
const col = hitColumn(ev.clientX, ev.clientY)
|
const col = hitColumn(ev.clientX, ev.clientY)
|
||||||
setOverCol(col)
|
setOverCol(col)
|
||||||
autoScroll(document.querySelector(scrollSelector), ev.clientX)
|
autoScroll(document.querySelector(scrollSelector), ev.clientX)
|
||||||
@@ -82,33 +103,78 @@ export default function useColumnDrag({ onDrop, scrollSelector = '.board-wrap' }
|
|||||||
const onPointerUp = (ev) => {
|
const onPointerUp = (ev) => {
|
||||||
const s = st.current
|
const s = st.current
|
||||||
if (s.active) {
|
if (s.active) {
|
||||||
|
lastEnd.current = Date.now()
|
||||||
const col = hitColumn(ev.clientX, ev.clientY)
|
const col = hitColumn(ev.clientX, ev.clientY)
|
||||||
if (col && s.task && col !== s.task.columnId) onDrop?.(s.task, col)
|
if (col && s.task && col !== s.task.columnId) onDropRef.current?.(s.task, col)
|
||||||
}
|
}
|
||||||
cleanup()
|
cleanup()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 指针监听装配 (capture + 非 passive touchmove; instant=true 把手路径
|
||||||
|
// 再挂 touchstart preventDefault, 浏览器完全不接管该触摸)
|
||||||
|
const armListeners = (ev, instant) => {
|
||||||
|
try { ev.currentTarget.setPointerCapture(ev.pointerId) } catch { /* ignore */ }
|
||||||
|
const el = ev.currentTarget
|
||||||
|
const move = (e2) => onPointerMove(e2)
|
||||||
|
const tm = (e2) => { if (st.current.active) e2.preventDefault() }
|
||||||
|
const ts = (e2) => e2.preventDefault()
|
||||||
|
const up = (e2) => { onPointerUp(e2); detach() }
|
||||||
|
const cancel = () => {
|
||||||
|
if (st.current.active) lastEnd.current = Date.now()
|
||||||
|
cleanup()
|
||||||
|
detach()
|
||||||
|
}
|
||||||
|
const detach = () => {
|
||||||
|
el.removeEventListener('pointermove', move)
|
||||||
|
el.removeEventListener('pointerup', up)
|
||||||
|
el.removeEventListener('pointercancel', cancel)
|
||||||
|
el.removeEventListener('touchmove', tm)
|
||||||
|
el.removeEventListener('touchstart', ts)
|
||||||
|
}
|
||||||
|
el.addEventListener('pointermove', move)
|
||||||
|
el.addEventListener('pointerup', up)
|
||||||
|
el.addEventListener('pointercancel', cancel)
|
||||||
|
el.addEventListener('touchmove', tm, { passive: false })
|
||||||
|
if (instant) el.addEventListener('touchstart', ts, { passive: false })
|
||||||
|
}
|
||||||
|
|
||||||
|
const startDrag = (ev, task, instant) => {
|
||||||
|
st.current = {
|
||||||
|
...st.current, startX: ev.clientX, startY: ev.clientY, task, active: false,
|
||||||
|
srcEl: ev.currentTarget.closest('.bcard'),
|
||||||
|
}
|
||||||
|
const activate = () => {
|
||||||
|
st.current.active = true
|
||||||
|
setDragTask(task)
|
||||||
|
if (navigator.vibrate) navigator.vibrate(15)
|
||||||
|
mountGhost(st.current.startX, st.current.startY)
|
||||||
|
}
|
||||||
|
if (instant) activate()
|
||||||
|
else st.current.timer = setTimeout(activate, 400)
|
||||||
|
armListeners(ev, instant)
|
||||||
|
}
|
||||||
|
|
||||||
/** 绑定到卡片: 需要卡片元素设置 touch-action: pan-y */
|
/** 绑定到卡片: 需要卡片元素设置 touch-action: pan-y */
|
||||||
const bind = useCallback((task) => ({
|
const bind = useCallback((task) => ({
|
||||||
onPointerDown: (ev) => {
|
onPointerDown: (ev) => {
|
||||||
// 捕获前先让事件正常冒泡(点击等); 只做长按计时
|
if (ev.button !== undefined && ev.button !== 0) return
|
||||||
onPointerDown(task)(ev)
|
startDrag(ev, task, false)
|
||||||
// 捕获: 后续 move/up 都派发给该元素, 不丢
|
|
||||||
try { ev.currentTarget.setPointerCapture(ev.pointerId) } catch { /* ignore */ }
|
|
||||||
const el = ev.currentTarget
|
|
||||||
const move = (e2) => onPointerMove(e2)
|
|
||||||
const up = (e2) => {
|
|
||||||
onPointerUp(e2)
|
|
||||||
el.removeEventListener('pointermove', move)
|
|
||||||
el.removeEventListener('pointerup', up)
|
|
||||||
el.removeEventListener('pointercancel', up)
|
|
||||||
}
|
|
||||||
el.addEventListener('pointermove', move)
|
|
||||||
el.addEventListener('pointerup', up)
|
|
||||||
el.addEventListener('pointercancel', up)
|
|
||||||
},
|
},
|
||||||
|
// 长按不弹系统菜单 (配合 CSS user-select:none, 防长按变文字选择)
|
||||||
|
onContextMenu: (ev) => ev.preventDefault(),
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}), [])
|
}), [])
|
||||||
|
|
||||||
return { dragTask, overCol, bind, cancel: cleanup }
|
/** 绑定到卡片左侧拖动把手: 把手需 touch-action: none, 按下立即拖动 */
|
||||||
|
const bindHandle = useCallback((task) => ({
|
||||||
|
onPointerDown: (ev) => {
|
||||||
|
if (ev.button !== undefined && ev.button !== 0) return
|
||||||
|
ev.stopPropagation() // 不触发卡片自身的长按路径
|
||||||
|
startDrag(ev, task, true)
|
||||||
|
},
|
||||||
|
onContextMenu: (ev) => ev.preventDefault(),
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}), [])
|
||||||
|
|
||||||
|
return { dragTask, overCol, bind, bindHandle, cancel: cleanup, justEnded }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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')
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
}
|
|
||||||
+19
-4
@@ -2,10 +2,10 @@ import { useEffect, useState } from 'react'
|
|||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { Avatar, Button, List, ProgressBar, Switch, Toast } from 'antd-mobile'
|
import { Avatar, Button, List, ProgressBar, Switch, Toast } from 'antd-mobile'
|
||||||
import { useAuth } from '../auth/AuthContext.jsx'
|
import { useAuth } from '../auth/AuthContext.jsx'
|
||||||
import { APP_VERSION, BUILD_ID } from '../version.js'
|
|
||||||
import {
|
import {
|
||||||
applyOtaUpdate,
|
applyOtaUpdate,
|
||||||
checkOtaUpdate,
|
checkOtaUpdate,
|
||||||
|
fmtBuildId,
|
||||||
getCurrentVersionInfo,
|
getCurrentVersionInfo,
|
||||||
getOtaDevMode,
|
getOtaDevMode,
|
||||||
getOtaVersionInfo,
|
getOtaVersionInfo,
|
||||||
@@ -14,6 +14,8 @@ import {
|
|||||||
setOtaDevMode,
|
setOtaDevMode,
|
||||||
} from '../api/ota.js'
|
} from '../api/ota.js'
|
||||||
|
|
||||||
|
const inTauri = typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window
|
||||||
|
|
||||||
// "我" 页: 顶部 NavBar + 用户头像卡片 + 信息列表 + OTA 升级区
|
// "我" 页: 顶部 NavBar + 用户头像卡片 + 信息列表 + OTA 升级区
|
||||||
// OTA 区:
|
// OTA 区:
|
||||||
// - 显示当前 APP_VERSION (前端包, 对应内置 dist)
|
// - 显示当前 APP_VERSION (前端包, 对应内置 dist)
|
||||||
@@ -38,6 +40,16 @@ export default function MePage() {
|
|||||||
const [otaInfo, setOtaInfo] = useState(null) // 检查结果中的 info
|
const [otaInfo, setOtaInfo] = useState(null) // 检查结果中的 info
|
||||||
const [otaVersion, setOtaVersion] = useState(null) // hotswap 当前激活的覆盖包版本
|
const [otaVersion, setOtaVersion] = useState(null) // hotswap 当前激活的覆盖包版本
|
||||||
const [progress, setProgress] = useState({ received: 0, total: 0 })
|
const [progress, setProgress] = useState({ received: 0, total: 0 })
|
||||||
|
const [shellVersion, setShellVersion] = useState('') // 外壳 (APK/桌面包) 版本, 独立维护
|
||||||
|
|
||||||
|
// 外壳版本: 经 Tauri API 读取 (Web 端为空, 底部显示 —)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!inTauri) return
|
||||||
|
import('@tauri-apps/api/app')
|
||||||
|
.then((m) => m.getVersion())
|
||||||
|
.then(setShellVersion)
|
||||||
|
.catch((e) => console.warn('[shell] getVersion failed:', e))
|
||||||
|
}, [])
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
// 判新模式: false(默认)=版本号比较, true=build sequence 比较; 值持久化在设备端
|
// 判新模式: false(默认)=版本号比较, true=build sequence 比较; 值持久化在设备端
|
||||||
const [devMode, setDevMode] = useState(() => getOtaDevMode())
|
const [devMode, setDevMode] = useState(() => getOtaDevMode())
|
||||||
@@ -137,7 +149,7 @@ export default function MePage() {
|
|||||||
|
|
||||||
{/* OTA 升级区 */}
|
{/* OTA 升级区 */}
|
||||||
<List className="me-list" mode="card" header="前端升级 (OTA)">
|
<List className="me-list" mode="card" header="前端升级 (OTA)">
|
||||||
<List.Item extra={'v' + cur.version + ' · ' + cur.build}>当前版本</List.Item>
|
<List.Item extra={'v' + cur.version + (cur.build ? ' · build ' + cur.build : '')}>当前版本</List.Item>
|
||||||
<List.Item extra={otaVersion?.active ? 'v' + (otaVersion.version || '?') : '内置'}>
|
<List.Item extra={otaVersion?.active ? 'v' + (otaVersion.version || '?') : '内置'}>
|
||||||
运行资源
|
运行资源
|
||||||
</List.Item>
|
</List.Item>
|
||||||
@@ -197,7 +209,7 @@ export default function MePage() {
|
|||||||
</div>
|
</div>
|
||||||
</List.Item>
|
</List.Item>
|
||||||
{otaState === 'available' && otaInfo && (
|
{otaState === 'available' && otaInfo && (
|
||||||
<List.Item extra={'v' + otaInfo.version}>
|
<List.Item extra={'v' + otaInfo.version + (otaInfo.build ? ' · build ' + otaInfo.build : '')}>
|
||||||
新版本
|
新版本
|
||||||
{otaInfo.bundle_size ? ` (${(otaInfo.bundle_size / 1024).toFixed(0)} KB)` : ''}
|
{otaInfo.bundle_size ? ` (${(otaInfo.bundle_size / 1024).toFixed(0)} KB)` : ''}
|
||||||
</List.Item>
|
</List.Item>
|
||||||
@@ -217,6 +229,9 @@ export default function MePage() {
|
|||||||
{otaInfo?.notes && (
|
{otaInfo?.notes && (
|
||||||
<List.Item description={otaInfo.notes}>发布说明</List.Item>
|
<List.Item description={otaInfo.notes}>发布说明</List.Item>
|
||||||
)}
|
)}
|
||||||
|
{otaInfo?.commit && (
|
||||||
|
<List.Item extra={String(otaInfo.commit).slice(0, 10)}>提交</List.Item>
|
||||||
|
)}
|
||||||
{error && (
|
{error && (
|
||||||
<List.Item>
|
<List.Item>
|
||||||
<span className="me-ota-error">{error}</span>
|
<span className="me-ota-error">{error}</span>
|
||||||
@@ -241,7 +256,7 @@ export default function MePage() {
|
|||||||
退出登录
|
退出登录
|
||||||
</List.Item>
|
</List.Item>
|
||||||
</List>
|
</List>
|
||||||
<div className="me-version">v{APP_VERSION} · build {BUILD_ID}</div>
|
<div className="me-version">外壳 v{shellVersion || '—'}</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
Button, Dialog, DotLoading, Empty, Input, Popup, Selector, Stepper, Toast,
|
Button, Dialog, DotLoading, Empty, Input, Popup, Selector, Stepper, Toast,
|
||||||
} from 'antd-mobile'
|
} from 'antd-mobile'
|
||||||
import RingProgress, { Celebrate } from '../../components/RingProgress.jsx'
|
import RingProgress, { Celebrate } from '../../components/RingProgress.jsx'
|
||||||
|
import useHabitDrag from '../../components/checkin/useHabitDrag.js'
|
||||||
import { checkinApi } from '../../api/checkin.js'
|
import { checkinApi } from '../../api/checkin.js'
|
||||||
import useRefreshOnVisible from '../../hooks/useRefreshOnVisible.js'
|
import useRefreshOnVisible from '../../hooks/useRefreshOnVisible.js'
|
||||||
import { toDateStr, dayLabel } from './date.js'
|
import { toDateStr, dayLabel } from './date.js'
|
||||||
@@ -38,10 +39,45 @@ export default function HabitView({ date, onDateChange }) {
|
|||||||
|
|
||||||
const items = useMemo(() => {
|
const items = useMemo(() => {
|
||||||
const list = data?.items || []
|
const list = data?.items || []
|
||||||
// 未完成在前, 已完成在后 (F-CK-16 默认排序)
|
// 手动排序优先 (sortOrder), 拖动排序后覆盖 F-CK-16 的"未完成在前"
|
||||||
return [...list].sort((a, b) => Number(a.completed) - Number(b.completed) || a.id - b.id)
|
return [...list].sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0) || a.id - b.id)
|
||||||
}, [data])
|
}, [data])
|
||||||
|
|
||||||
|
// 按周期分组 (每天/每周/每月/每年), 组内顺序 = 手动排序
|
||||||
|
const groups = useMemo(() => {
|
||||||
|
const out = []
|
||||||
|
for (const p of PERIOD_OPTIONS) {
|
||||||
|
const list = items.filter((i) => i.period === p.value)
|
||||||
|
if (list.length) out.push({ key: p.value, label: p.label, list })
|
||||||
|
}
|
||||||
|
const known = new Set(PERIOD_OPTIONS.map((p) => p.value))
|
||||||
|
const rest = items.filter((i) => !known.has(i.period))
|
||||||
|
if (rest.length) out.push({ key: 'other', label: '其他', list: rest })
|
||||||
|
return out
|
||||||
|
}, [items])
|
||||||
|
|
||||||
|
const drag = useHabitDrag({ onDrop: reorderHabit })
|
||||||
|
|
||||||
|
// 组内拖动落位: 重排后按展平顺序整体重写 sortOrder
|
||||||
|
async function reorderHabit(dragged, targetId, after) {
|
||||||
|
const snapshot = data
|
||||||
|
const g = groups.find((x) => x.key === dragged.period)
|
||||||
|
if (!g) return
|
||||||
|
const rest = g.list.filter((i) => i.id !== dragged.id)
|
||||||
|
const ti = rest.findIndex((i) => i.id === targetId)
|
||||||
|
if (ti < 0) return
|
||||||
|
rest.splice(after ? ti + 1 : ti, 0, dragged)
|
||||||
|
const flat = groups.map((x) => (x.key === g.key ? rest : x.list)).flat()
|
||||||
|
const withOrder = flat.map((it, i) => ({ ...it, sortOrder: i }))
|
||||||
|
setData((d) => ({ ...d, items: withOrder }))
|
||||||
|
try {
|
||||||
|
await checkinApi.reorderHabits(flat.map((i) => i.id))
|
||||||
|
} catch (e) {
|
||||||
|
setData(snapshot)
|
||||||
|
Toast.show({ content: e.message || '排序保存失败' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const fireBurst = (id) => setBursts((m) => ({ ...m, [id]: (m[id] || 0) + 1 }))
|
const fireBurst = (id) => setBursts((m) => ({ ...m, [id]: (m[id] || 0) + 1 }))
|
||||||
|
|
||||||
// 乐观更新: 先本地 +1, 成功用服务端结果覆盖, 失败回滚 (F-SY-06/E-02)
|
// 乐观更新: 先本地 +1, 成功用服务端结果覆盖, 失败回滚 (F-SY-06/E-02)
|
||||||
@@ -169,8 +205,25 @@ export default function HabitView({ date, onDateChange }) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="habit-list">
|
<div className="habit-list">
|
||||||
{items.map((item) => (
|
{groups.map((g) => (
|
||||||
<div key={item.id} className={'habit-card' + (item.completed ? ' done' : '')}>
|
<div key={g.key}>
|
||||||
|
<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
|
<RingProgress
|
||||||
percent={item.percent}
|
percent={item.percent}
|
||||||
done={item.done}
|
done={item.done}
|
||||||
@@ -178,7 +231,7 @@ export default function HabitView({ date, onDateChange }) {
|
|||||||
over={item.over}
|
over={item.over}
|
||||||
completed={item.completed}
|
completed={item.completed}
|
||||||
color={item.color}
|
color={item.color}
|
||||||
onClick={() => doCheck(item)}
|
onClick={() => { if (!drag.justEnded()) doCheck(item) }}
|
||||||
/>
|
/>
|
||||||
<div className="habit-main">
|
<div className="habit-main">
|
||||||
<div className="habit-name">{item.name}</div>
|
<div className="habit-name">{item.name}</div>
|
||||||
@@ -198,28 +251,31 @@ export default function HabitView({ date, onDateChange }) {
|
|||||||
<Button
|
<Button
|
||||||
size="mini"
|
size="mini"
|
||||||
color={item.completed ? 'default' : 'primary'}
|
color={item.completed ? 'default' : 'primary'}
|
||||||
onClick={() => doCheck(item)}
|
onClick={() => { if (!drag.justEnded()) doCheck(item) }}
|
||||||
>
|
>
|
||||||
{item.completed ? '再打一次' : '打卡'}
|
{item.completed ? '再打一次' : '打卡'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="habit-foot">
|
<div className="habit-foot">
|
||||||
{item.done > 0 && (
|
{item.done > 0 && (
|
||||||
<span className="habit-link" onClick={() => doUncheck(item)}>撤销</span>
|
<span className="habit-link" onClick={() => { if (!drag.justEnded()) doUncheck(item) }}>撤销</span>
|
||||||
)}
|
)}
|
||||||
<span
|
<span
|
||||||
className="habit-link"
|
className="habit-link"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
if (drag.justEnded()) return
|
||||||
setForm({ id: item.id, name: item.name, period: item.period, target: item.target, color: item.color, note: item.note })
|
setForm({ id: item.id, name: item.name, period: item.period, target: item.target, color: item.color, note: item.note })
|
||||||
setShowForm(true)
|
setShowForm(true)
|
||||||
}}
|
}}
|
||||||
>编辑</span>
|
>编辑</span>
|
||||||
<span className="habit-link danger" onClick={() => removeHabit(item)}>归档</span>
|
<span className="habit-link danger" onClick={() => { if (!drag.justEnded()) removeHabit(item) }}>归档</span>
|
||||||
</div>
|
</div>
|
||||||
<Celebrate fire={bursts[item.id] || 0} color={item.color} />
|
<Celebrate fire={bursts[item.id] || 0} color={item.color} />
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* 新建 / 编辑 */}
|
{/* 新建 / 编辑 */}
|
||||||
<Popup
|
<Popup
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
Button, Dialog, DotLoading, Empty, Input, Popup, Selector, Stepper, Toast,
|
Button, Dialog, DotLoading, Empty, Input, Popup, Selector, Stepper, Toast,
|
||||||
} from 'antd-mobile'
|
} from 'antd-mobile'
|
||||||
import RingProgress, { Celebrate } from '../../components/RingProgress.jsx'
|
import RingProgress, { Celebrate } from '../../components/RingProgress.jsx'
|
||||||
|
import useHabitDrag from '../../components/checkin/useHabitDrag.js'
|
||||||
import { checkinApi } from '../../api/checkin.js'
|
import { checkinApi } from '../../api/checkin.js'
|
||||||
import useRefreshOnVisible from '../../hooks/useRefreshOnVisible.js'
|
import useRefreshOnVisible from '../../hooks/useRefreshOnVisible.js'
|
||||||
import {
|
import {
|
||||||
@@ -77,6 +78,8 @@ export default function MergedView({ date, onDateChange }) {
|
|||||||
|
|
||||||
// 按周期分组: 每天 → 每周 → 每月. "每年" 不在合并视图中展示, 但若存在
|
// 按周期分组: 每天 → 每周 → 每月. "每年" 不在合并视图中展示, 但若存在
|
||||||
// 历史 year 项则归入"每月"之外的单列, 避免数据凭空消失.
|
// 历史 year 项则归入"每月"之外的单列, 避免数据凭空消失.
|
||||||
|
// 组内顺序: 手动排序 (sortOrder) 优先 —— 拖动排序需要稳定口径,
|
||||||
|
// 服务端 /habits 已按 sortOrder 返回, 此处仅兜底。
|
||||||
const groups = useMemo(() => {
|
const groups = useMemo(() => {
|
||||||
const bucket = new Map()
|
const bucket = new Map()
|
||||||
for (const it of items) {
|
for (const it of items) {
|
||||||
@@ -84,9 +87,8 @@ export default function MergedView({ date, onDateChange }) {
|
|||||||
if (!bucket.has(key)) bucket.set(key, [])
|
if (!bucket.has(key)) bucket.set(key, [])
|
||||||
bucket.get(key).push(it)
|
bucket.get(key).push(it)
|
||||||
}
|
}
|
||||||
// 组内排序: 未完成在前, 已完成在后 (沿用 F-CK-16 默认口径)
|
|
||||||
for (const arr of bucket.values()) {
|
for (const arr of bucket.values()) {
|
||||||
arr.sort((a, b) => Number(a.completed) - Number(b.completed) || a.id - b.id)
|
arr.sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0) || a.id - b.id)
|
||||||
}
|
}
|
||||||
const order = [...GROUP_ORDER, 'other']
|
const order = [...GROUP_ORDER, 'other']
|
||||||
return order.filter((k) => bucket.has(k)).map((k) => ({
|
return order.filter((k) => bucket.has(k)).map((k) => ({
|
||||||
@@ -96,6 +98,37 @@ export default function MergedView({ date, onDateChange }) {
|
|||||||
}))
|
}))
|
||||||
}, [items])
|
}, [items])
|
||||||
|
|
||||||
|
const drag = useHabitDrag({ onDrop: reorderHabit })
|
||||||
|
|
||||||
|
// 拖动落位: 组内排序; 落到其他周期组 = 同时改周期并落位。
|
||||||
|
// 重排后按渲染分组顺序整体展平重写 sortOrder。
|
||||||
|
async function reorderHabit(dragged, targetId, after, targetPeriod) {
|
||||||
|
const snapshot = detail
|
||||||
|
const g = groups.find((x) => x.items.some((i) => i.id === targetId))
|
||||||
|
if (!g) return
|
||||||
|
const target = g.items.find((i) => i.id === targetId)
|
||||||
|
const rest = g.items.filter((i) => i.id !== dragged.id)
|
||||||
|
const ti = rest.findIndex((i) => i.id === targetId)
|
||||||
|
if (ti < 0) return
|
||||||
|
const crossed = targetPeriod && targetPeriod !== dragged.period
|
||||||
|
const moved = crossed
|
||||||
|
? { ...dragged, period: targetPeriod, periodLabel: GROUP_LABEL[targetPeriod] || target.periodLabel }
|
||||||
|
: dragged
|
||||||
|
rest.splice(after ? ti + 1 : ti, 0, moved)
|
||||||
|
// 展平时把拖动项从原组剔除 (跨组场景原组可能仍包含它)
|
||||||
|
const flat = groups
|
||||||
|
.map((x) => (x.key === g.key ? rest : x.items.filter((i) => i.id !== dragged.id)))
|
||||||
|
.flat()
|
||||||
|
setDetail((d) => ({ ...d, items: flat.map((it, i) => ({ ...it, sortOrder: i })) }))
|
||||||
|
try {
|
||||||
|
if (crossed) await checkinApi.updateHabit(dragged.id, { period: targetPeriod })
|
||||||
|
await checkinApi.reorderHabits(flat.map((i) => i.id))
|
||||||
|
} catch (e) {
|
||||||
|
setDetail(snapshot)
|
||||||
|
Toast.show({ content: e.message || '排序保存失败' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const summary = detail?.summary || { total: 0, done: 0 }
|
const summary = detail?.summary || { total: 0, done: 0 }
|
||||||
const fireBurst = (id) => setBursts((m) => ({ ...m, [id]: (m[id] || 0) + 1 }))
|
const fireBurst = (id) => setBursts((m) => ({ ...m, [id]: (m[id] || 0) + 1 }))
|
||||||
|
|
||||||
@@ -290,7 +323,19 @@ export default function MergedView({ date, onDateChange }) {
|
|||||||
<div key={g.key}>
|
<div key={g.key}>
|
||||||
<div className="mv-ghead">{g.label}<span className="cnt">{g.items.length} 项</span></div>
|
<div className="mv-ghead">{g.label}<span className="cnt">{g.items.length} 项</span></div>
|
||||||
{g.items.map((item) => (
|
{g.items.map((item) => (
|
||||||
<div key={item.id} className={'mv-card' + (item.completed ? ' done' : '')}>
|
<div
|
||||||
|
key={item.id}
|
||||||
|
data-habit-row
|
||||||
|
data-habit-id={item.id}
|
||||||
|
data-habit-period={item.period}
|
||||||
|
className={
|
||||||
|
'mv-card'
|
||||||
|
+ (item.completed ? ' done' : '')
|
||||||
|
+ (drag.dragItem?.id === item.id ? ' dragging' : '')
|
||||||
|
+ (drag.over?.id === item.id ? ` dragover ${drag.over.after ? 'after' : 'before'}` : '')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span className="grip" title="拖动排序" {...drag.bindHandle(item)}>⠿</span>
|
||||||
<RingProgress
|
<RingProgress
|
||||||
size={42}
|
size={42}
|
||||||
stroke={5}
|
stroke={5}
|
||||||
@@ -300,7 +345,7 @@ export default function MergedView({ date, onDateChange }) {
|
|||||||
over={item.over}
|
over={item.over}
|
||||||
completed={item.completed}
|
completed={item.completed}
|
||||||
color={item.color}
|
color={item.color}
|
||||||
onClick={() => doCheck(item)}
|
onClick={() => { if (!drag.justEnded()) doCheck(item) }}
|
||||||
/>
|
/>
|
||||||
<div className="mv-main">
|
<div className="mv-main">
|
||||||
<div className="mv-name">{item.name}</div>
|
<div className="mv-name">{item.name}</div>
|
||||||
@@ -314,7 +359,7 @@ export default function MergedView({ date, onDateChange }) {
|
|||||||
) : (
|
) : (
|
||||||
<div
|
<div
|
||||||
className={'mv-btn' + (item.completed ? ' grey' : '')}
|
className={'mv-btn' + (item.completed ? ' grey' : '')}
|
||||||
onClick={() => doCheck(item)}
|
onClick={() => { if (!drag.justEnded()) doCheck(item) }}
|
||||||
>
|
>
|
||||||
{item.completed ? '再打一次' : '打卡'}
|
{item.completed ? '再打一次' : '打卡'}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,8 +1,39 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import TaskCard from '../../components/task/TaskCard.jsx'
|
import TaskCard from '../../components/task/TaskCard.jsx'
|
||||||
import useColumnDrag from '../../components/task/useColumnDrag.js'
|
import useColumnDrag from '../../components/task/useColumnDrag.js'
|
||||||
|
|
||||||
|
// 列体 + 右缘滚动条: 滚动时出现细滚动条, 停止约 700ms 后淡出
|
||||||
|
// (移动端 WebView 原生滚动条是即隐式的, 看板列窄、需要明确的滚动指示)
|
||||||
|
function ScrollBody({ children }) {
|
||||||
|
const ref = useRef(null)
|
||||||
|
const fade = useRef(null)
|
||||||
|
const [bar, setBar] = useState(null) // { h, y } 滚动条几何
|
||||||
|
|
||||||
|
useEffect(() => () => clearTimeout(fade.current), [])
|
||||||
|
|
||||||
|
function onScroll() {
|
||||||
|
const el = ref.current
|
||||||
|
if (!el) return
|
||||||
|
const max = el.scrollHeight - el.clientHeight
|
||||||
|
if (max <= 2) { setBar(null); return }
|
||||||
|
const h = Math.max(28, Math.round(el.clientHeight * (el.clientHeight / el.scrollHeight)))
|
||||||
|
const y = Math.round((el.scrollTop / max) * (el.clientHeight - h))
|
||||||
|
setBar({ h, y })
|
||||||
|
clearTimeout(fade.current)
|
||||||
|
fade.current = setTimeout(() => setBar(null), 700)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bcol-main">
|
||||||
|
<div className="bcol-body" ref={ref} onScroll={onScroll}>{children}</div>
|
||||||
|
<div className={'bcol-bar' + (bar ? ' on' : '')} style={bar ? { top: bar.y, height: bar.h } : undefined} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// 看板视图 (T-BD-01, T-UI-07)
|
// 看板视图 (T-BD-01, T-UI-07)
|
||||||
// 拖动 = 长按 400ms 激活, 卡片原位抬起, 指针划过列高亮, 松手落列 (重点设计 §7.1)
|
// 拖动 = 长按 400ms 激活, 卡片原位抬起, 指针划过列高亮, 松手落列 (重点设计 §7.1)
|
||||||
|
// 横向滚列; 列内独立纵向滚动, 列头固定, 滚动时右缘显示滚动条
|
||||||
// 不做"拖到卡片上转子任务" —— 统一走详情页「转为子任务」
|
// 不做"拖到卡片上转子任务" —— 统一走详情页「转为子任务」
|
||||||
export default function BoardView({
|
export default function BoardView({
|
||||||
tasks, columns, onMoveColumn, onOpen, onToggleSub, onToggle, onMore, onQuickAdd,
|
tasks, columns, onMoveColumn, onOpen, onToggleSub, onToggle, onMore, onQuickAdd,
|
||||||
@@ -33,14 +64,14 @@ export default function BoardView({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bcol-body">
|
<ScrollBody>
|
||||||
{list.map((t) => (
|
{list.map((t) => (
|
||||||
<TaskCard
|
<TaskCard
|
||||||
key={t.id}
|
key={t.id}
|
||||||
task={t}
|
task={t}
|
||||||
dragging={drag.dragTask?.id === t.id}
|
dragging={drag.dragTask?.id === t.id}
|
||||||
bindDrag={drag.bind}
|
bindDrag={drag.bind}
|
||||||
onClick={() => { if (!drag.dragTask) onOpen?.(t) }}
|
onClick={() => { if (!drag.dragTask && !drag.justEnded()) onOpen?.(t) }}
|
||||||
onToggleSub={onToggleSub}
|
onToggleSub={onToggleSub}
|
||||||
onToggle={onToggle}
|
onToggle={onToggle}
|
||||||
onMore={onMore}
|
onMore={onMore}
|
||||||
@@ -48,7 +79,7 @@ export default function BoardView({
|
|||||||
))}
|
))}
|
||||||
{list.length === 0 && <div className="bcol-empty">暂无任务</div>}
|
{list.length === 0 && <div className="bcol-empty">暂无任务</div>}
|
||||||
<div className="badd" onClick={() => onQuickAdd?.(col.id)}>+ 添加任务</div>
|
<div className="badd" onClick={() => onQuickAdd?.(col.id)}>+ 添加任务</div>
|
||||||
</div>
|
</ScrollBody>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -1,13 +1,16 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import TaskRow from '../../components/task/TaskRow.jsx'
|
import TaskRow from '../../components/task/TaskRow.jsx'
|
||||||
|
import useRowDrag from '../../components/task/useRowDrag.js'
|
||||||
|
|
||||||
// 列表视图 (T-VW-01, T-ST-04/05)
|
// 列表视图 (T-VW-01, T-ST-04/05)
|
||||||
// 按看板列分组展示; 子任务缩进 + 折叠展开
|
// 按看板列分组展示; 子任务缩进 + 折叠展开
|
||||||
// 子任务的添加只在任务详情页完成 (或把别的任务转为子任务), 列表页不放入口
|
// 换列: 行/把手拖动, 跟手浮层, 手指划到哪个分组松手就落到哪 (与看板一致)
|
||||||
|
// (列表只展示有任务的列, 空列需到看板视图拖入)
|
||||||
export default function ListView({
|
export default function ListView({
|
||||||
tasks, columns, onToggle, onOpen, onMore, onLongPress,
|
tasks, columns, onToggle, onOpen, onMore, onMoveColumn, onReorder,
|
||||||
}) {
|
}) {
|
||||||
const [collapsed, setCollapsed] = useState({})
|
const [collapsed, setCollapsed] = useState({})
|
||||||
|
const drag = useRowDrag({ columns, onDrop: onMoveColumn, onReorder })
|
||||||
|
|
||||||
if (!tasks || tasks.length === 0) {
|
if (!tasks || tasks.length === 0) {
|
||||||
return (
|
return (
|
||||||
@@ -32,7 +35,7 @@ export default function ListView({
|
|||||||
return (
|
return (
|
||||||
<div className="list-body">
|
<div className="list-body">
|
||||||
{groups.map((g) => (
|
{groups.map((g) => (
|
||||||
<div key={g.key}>
|
<div key={g.key} data-column-id={g.key === 'other' ? undefined : g.key}>
|
||||||
<div className="group-title">
|
<div className="group-title">
|
||||||
{g.name}
|
{g.name}
|
||||||
<span className="cnt">{g.list.length}</span>
|
<span className="cnt">{g.list.length}</span>
|
||||||
@@ -41,19 +44,22 @@ export default function ListView({
|
|||||||
{g.list.map((t) => {
|
{g.list.map((t) => {
|
||||||
const kids = t.children || []
|
const kids = t.children || []
|
||||||
const isCollapsed = !!collapsed[t.id]
|
const isCollapsed = !!collapsed[t.id]
|
||||||
|
const dragging = drag.drag?.task?.id === t.id ? drag.drag : null
|
||||||
return (
|
return (
|
||||||
<div key={t.id}>
|
<div key={t.id}>
|
||||||
<TaskRow
|
<TaskRow
|
||||||
task={t}
|
task={t}
|
||||||
onToggle={onToggle}
|
onToggle={onToggle}
|
||||||
onClick={() => onOpen(t)}
|
onClick={() => { if (!drag.justEnded()) onOpen(t) }}
|
||||||
onMore={onMore}
|
onMore={onMore}
|
||||||
onLongPress={onLongPress}
|
bindDragHandle={drag.bindHandle}
|
||||||
|
dragging={!!dragging}
|
||||||
|
dragOver={drag.over?.id === t.id ? drag.over : null}
|
||||||
/>
|
/>
|
||||||
{kids.length > 0 && (
|
{kids.length > 0 && (
|
||||||
<>
|
<>
|
||||||
{!isCollapsed && kids.map((c) => (
|
{!isCollapsed && kids.map((c) => (
|
||||||
<TaskRow key={c.id} task={c} indent onToggle={onToggle} onClick={() => onOpen(c)} onMore={onMore} onLongPress={onLongPress} />
|
<TaskRow key={c.id} task={c} indent onToggle={onToggle} onClick={() => onOpen(c)} onMore={onMore} />
|
||||||
))}
|
))}
|
||||||
<div
|
<div
|
||||||
className="collapse"
|
className="collapse"
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
import { useState } from 'react'
|
import { useRef, useState } from 'react'
|
||||||
import { Dialog, Input, Popup, Toast } from 'antd-mobile'
|
import { Dialog, Input, Popup, Toast } from 'antd-mobile'
|
||||||
import { projectApi } from '../../api/task.js'
|
import { projectApi } from '../../api/task.js'
|
||||||
import { useAuth } from '../../auth/AuthContext.jsx'
|
import { useAuth } from '../../auth/AuthContext.jsx'
|
||||||
|
import useKeyboardInset from '../../hooks/useKeyboardInset.js'
|
||||||
import useOverlayBack from '../../hooks/useOverlayBack.js'
|
import useOverlayBack from '../../hooks/useOverlayBack.js'
|
||||||
import ColumnSettings from './ColumnSettings.jsx'
|
import ColumnSettings from './ColumnSettings.jsx'
|
||||||
import ProjectMembers from './ProjectMembers.jsx'
|
import ProjectMembers from './ProjectMembers.jsx'
|
||||||
|
import { DEFAULT_KINDS, kindColor } from './kind.js'
|
||||||
|
import { colorOfProject } from './projectIcon.js'
|
||||||
|
|
||||||
// 项目管理 (T-PJ-11/12): 成员 / 看板列 / 项目信息 三个分区的统一入口
|
// 项目管理 (T-PJ-11/12): 成员 / 看板列 / 项目信息 三个分区的统一入口
|
||||||
// onExited: 项目被删除/退出/归档后, 上层应离开该项目(回落个人默认项目)
|
// onExited: 项目被删除/退出/归档后, 上层应离开该项目(回落个人默认项目)
|
||||||
@@ -15,6 +18,12 @@ export default function ProjectManage({ project, onClose, onChanged, onExited })
|
|||||||
const [name, setName] = useState(project.name)
|
const [name, setName] = useState(project.name)
|
||||||
// 信息块编辑 (T-PJ-13): index=-1 新增, >=0 编辑既有块
|
// 信息块编辑 (T-PJ-13): index=-1 新增, >=0 编辑既有块
|
||||||
const [noteEdit, setNoteEdit] = useState(null)
|
const [noteEdit, setNoteEdit] = useState(null)
|
||||||
|
// 任务类型编辑: 编辑中的类型列表 + 新增输入
|
||||||
|
const [kindEdit, setKindEdit] = useState(false)
|
||||||
|
const [kinds, setKinds] = useState([])
|
||||||
|
const [newKind, setNewKind] = useState('')
|
||||||
|
// 项目图标更换: 隐藏 file input
|
||||||
|
const iconInputRef = useRef(null)
|
||||||
|
|
||||||
const members = project.members || []
|
const members = project.members || []
|
||||||
const notes = Array.isArray(project.notes) ? project.notes : []
|
const notes = Array.isArray(project.notes) ? project.notes : []
|
||||||
@@ -23,6 +32,8 @@ export default function ProjectManage({ project, onClose, onChanged, onExited })
|
|||||||
|
|
||||||
// 返回键: 内层视图(列设置/成员)先回主区, 主区再关闭整个项目管理
|
// 返回键: 内层视图(列设置/成员)先回主区, 主区再关闭整个项目管理
|
||||||
useOverlayBack(true, () => { if (view !== 'main') setView('main'); else onClose() })
|
useOverlayBack(true, () => { if (view !== 'main') setView('main'); else onClose() })
|
||||||
|
// 键盘弹出插入量: 信息块编辑弹层用
|
||||||
|
const kb = useKeyboardInset()
|
||||||
|
|
||||||
if (view === 'columns') {
|
if (view === 'columns') {
|
||||||
return (
|
return (
|
||||||
@@ -120,8 +131,71 @@ export default function ProjectManage({ project, onClose, onChanged, onExited })
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function quit() {
|
// 任务类型: 添加 / 删除 (至少保留一个)
|
||||||
const ok = await Dialog.confirm({
|
function addKind() {
|
||||||
|
const k = newKind.trim().slice(0, 20)
|
||||||
|
if (!k) return
|
||||||
|
if (kinds.includes(k)) { Toast.show({ content: '该类型已存在' }); return }
|
||||||
|
setKinds((p) => [...p, k])
|
||||||
|
setNewKind('')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveKinds() {
|
||||||
|
if (!kinds.length) { Toast.show({ content: '至少保留一个任务类型' }); return }
|
||||||
|
try {
|
||||||
|
await projectApi.update(project.id, { taskKinds: kinds })
|
||||||
|
Toast.show({ content: '已保存' })
|
||||||
|
setKindEdit(false)
|
||||||
|
onChanged?.()
|
||||||
|
} catch (e) {
|
||||||
|
Toast.show({ content: e.message })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 项目图标: 图片裁为 96x96 cover JPEG data URL (保持现有尺寸/圆角由 CSS 承担)
|
||||||
|
function pickIcon() { iconInputRef.current?.click() }
|
||||||
|
|
||||||
|
function onIconFile(e) {
|
||||||
|
const file = e.target.files?.[0]
|
||||||
|
e.target.value = ''
|
||||||
|
if (!file || !file.type.startsWith('image/')) return
|
||||||
|
const url = URL.createObjectURL(file)
|
||||||
|
const img = new Image()
|
||||||
|
img.onload = async () => {
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
const S = 96
|
||||||
|
const c = document.createElement('canvas')
|
||||||
|
c.width = S
|
||||||
|
c.height = S
|
||||||
|
const ctx = c.getContext('2d')
|
||||||
|
const r = Math.max(S / img.width, S / img.height)
|
||||||
|
const w = img.width * r
|
||||||
|
const h = img.height * r
|
||||||
|
ctx.drawImage(img, (S - w) / 2, (S - h) / 2, w, h)
|
||||||
|
const dataUrl = c.toDataURL('image/jpeg', 0.85)
|
||||||
|
try {
|
||||||
|
await projectApi.update(project.id, { icon: dataUrl })
|
||||||
|
Toast.show({ content: '图标已更新' })
|
||||||
|
onChanged?.()
|
||||||
|
} catch (err) {
|
||||||
|
Toast.show({ content: err.message || '图标保存失败' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
img.onerror = () => URL.revokeObjectURL(url)
|
||||||
|
img.src = url
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clearIcon() {
|
||||||
|
try {
|
||||||
|
await projectApi.update(project.id, { icon: null })
|
||||||
|
Toast.show({ content: '已恢复默认' })
|
||||||
|
onChanged?.()
|
||||||
|
} catch (e) {
|
||||||
|
Toast.show({ content: e.message })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function quit() { const ok = await Dialog.confirm({
|
||||||
content: '退出该项目?退出后将不再可见。',
|
content: '退出该项目?退出后将不再可见。',
|
||||||
confirmText: '退出',
|
confirmText: '退出',
|
||||||
})
|
})
|
||||||
@@ -164,6 +238,16 @@ export default function ProjectManage({ project, onClose, onChanged, onExited })
|
|||||||
<span className="link">列设置 ›</span>
|
<span className="link">列设置 ›</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="section-h" style={{ margin: '14px 14px 8px' }}>任务类型</div>
|
||||||
|
<div className="set-row" style={{ padding: '12px 14px' }} onClick={() => { setKinds(project.taskKinds || DEFAULT_KINDS); setNewKind(''); setKindEdit(true) }}>
|
||||||
|
<div className="kind-chip-row" style={{ flex: 1 }}>
|
||||||
|
{(project.taskKinds || DEFAULT_KINDS).map((k) => (
|
||||||
|
<span key={k} className="kind-tag" style={{ background: kindColor(k) }}>{k}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<span className="link">编辑 ›</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="section-h" style={{ margin: '14px 14px 8px' }}>信息块({notes.length})</div>
|
<div className="section-h" style={{ margin: '14px 14px 8px' }}>信息块({notes.length})</div>
|
||||||
{notes.map((n, i) => (
|
{notes.map((n, i) => (
|
||||||
<div
|
<div
|
||||||
@@ -184,6 +268,25 @@ export default function ProjectManage({ project, onClose, onChanged, onExited })
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="section-h" style={{ margin: '14px 14px 8px' }}>项目信息</div>
|
<div className="section-h" style={{ margin: '14px 14px 8px' }}>项目信息</div>
|
||||||
|
<div className="set-row" style={{ padding: '12px 14px' }}>
|
||||||
|
<span className="pn" style={{ flex: 1, fontSize: 13.5 }}>项目图标</span>
|
||||||
|
<div
|
||||||
|
className="pavatar"
|
||||||
|
style={{
|
||||||
|
width: 34, height: 34, borderRadius: 9, fontSize: 14,
|
||||||
|
background: project.icon
|
||||||
|
? `url(${project.icon}) center/cover no-repeat`
|
||||||
|
: colorOfProject(project),
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
onClick={pickIcon}
|
||||||
|
>
|
||||||
|
{project.icon ? '' : (project.name || '?').slice(0, 1)}
|
||||||
|
</div>
|
||||||
|
{project.icon && <span className="link" style={{ marginLeft: 10 }} onClick={clearIcon}>恢复</span>}
|
||||||
|
<span className="link" style={{ marginLeft: 10 }} onClick={pickIcon}>更换</span>
|
||||||
|
<input ref={iconInputRef} type="file" accept="image/*" hidden onChange={onIconFile} />
|
||||||
|
</div>
|
||||||
<div className="set-row" style={{ padding: '12px 14px' }} onClick={() => { setName(project.name); setRenaming(true) }}>
|
<div className="set-row" style={{ padding: '12px 14px' }} onClick={() => { setName(project.name); setRenaming(true) }}>
|
||||||
<span className="pn" style={{ flex: 1, fontSize: 13.5 }}>项目名称</span>
|
<span className="pn" style={{ flex: 1, fontSize: 13.5 }}>项目名称</span>
|
||||||
<span style={{ fontSize: 12.5, color: '#999' }}>{project.name}</span>
|
<span style={{ fontSize: 12.5, color: '#999' }}>{project.name}</span>
|
||||||
@@ -225,12 +328,60 @@ export default function ProjectManage({ project, onClose, onChanged, onExited })
|
|||||||
onClose={() => setRenaming(false)}
|
onClose={() => setRenaming(false)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* 任务类型编辑: chips 删除 + 输入新增 */}
|
||||||
|
<Popup
|
||||||
|
visible={kindEdit}
|
||||||
|
onMaskClick={() => setKindEdit(false)}
|
||||||
|
bodyStyle={{
|
||||||
|
borderTopLeftRadius: 12,
|
||||||
|
borderTopRightRadius: 12,
|
||||||
|
padding: `16px 16px calc(env(safe-area-inset-bottom, 0px) + 20px + ${kb}px)`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="sheet-title">任务类型</div>
|
||||||
|
<div className="sheet-sub">任务的分类标签,显示在任务卡片左下角</div>
|
||||||
|
|
||||||
|
<div className="kind-chip-row" style={{ marginTop: 12 }}>
|
||||||
|
{kinds.map((k) => (
|
||||||
|
<span key={k} className="kind-chip" style={{ background: kindColor(k) }}>
|
||||||
|
{k}
|
||||||
|
<span
|
||||||
|
className="x"
|
||||||
|
onClick={() => setKinds((p) => p.filter((x) => x !== k))}
|
||||||
|
>×</span>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="extra-row" style={{ display: 'flex', gap: 8, marginTop: 14 }}>
|
||||||
|
<input
|
||||||
|
className="inp"
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
placeholder="新类型名称"
|
||||||
|
value={newKind}
|
||||||
|
maxLength={20}
|
||||||
|
onChange={(e) => setNewKind(e.target.value)}
|
||||||
|
onKeyDown={(e) => { if (e.key === 'Enter') addKind() }}
|
||||||
|
/>
|
||||||
|
<span className="mini-btn solid" style={{ padding: '0 16px', display: 'inline-flex', alignItems: 'center' }} onClick={addKind}>添加</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="btn-primary" onClick={saveKinds}>保存</div>
|
||||||
|
<div className="btn-ghost" onClick={() => setKindEdit(false)}>取消</div>
|
||||||
|
</Popup>
|
||||||
|
|
||||||
{/* 信息块编辑 (T-PJ-13): 标题 + 长文本, 独立编辑 */}
|
{/* 信息块编辑 (T-PJ-13): 标题 + 长文本, 独立编辑 */}
|
||||||
<Popup
|
<Popup
|
||||||
visible={!!noteEdit}
|
visible={!!noteEdit}
|
||||||
onMaskClick={() => setNoteEdit(null)}
|
onMaskClick={() => setNoteEdit(null)}
|
||||||
position="bottom"
|
position="bottom"
|
||||||
className="kb-safe"
|
bodyStyle={{
|
||||||
|
borderTopLeftRadius: 12,
|
||||||
|
borderTopRightRadius: 12,
|
||||||
|
maxHeight: '86vh',
|
||||||
|
overflowY: 'auto',
|
||||||
|
padding: `16px 16px calc(env(safe-area-inset-bottom, 0px) + 20px + ${kb}px)`,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div className="sheet-title">{noteEdit?.index >= 0 ? '编辑信息块' : '添加信息块'}</div>
|
<div className="sheet-title">{noteEdit?.index >= 0 ? '编辑信息块' : '添加信息块'}</div>
|
||||||
<div className="sheet-sub">用于记录 Readme 等项目相关长文本,每块独立编辑</div>
|
<div className="sheet-sub">用于记录 Readme 等项目相关长文本,每块独立编辑</div>
|
||||||
@@ -249,7 +400,6 @@ export default function ProjectManage({ project, onClose, onChanged, onExited })
|
|||||||
<textarea
|
<textarea
|
||||||
className="inp"
|
className="inp"
|
||||||
rows={10}
|
rows={10}
|
||||||
onFocus={(e) => { setTimeout(() => e.target.scrollIntoView({ block: 'center', behavior: 'smooth' }), 300) }}
|
|
||||||
placeholder="支持多行长文本…"
|
placeholder="支持多行长文本…"
|
||||||
value={noteEdit?.content || ''}
|
value={noteEdit?.content || ''}
|
||||||
onChange={(e) => setNoteEdit((p) => ({ ...(p || {}), content: e.target.value }))}
|
onChange={(e) => setNoteEdit((p) => ({ ...(p || {}), content: e.target.value }))}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { ActionSheet, Dialog, DotLoading, Toast } from 'antd-mobile'
|
|||||||
import { projectApi, taskApi } from '../../api/task.js'
|
import { projectApi, taskApi } from '../../api/task.js'
|
||||||
import { filterVisible, msToNextBoundary } from './date.js'
|
import { filterVisible, msToNextBoundary } from './date.js'
|
||||||
import { getViewMode, setViewMode } from './prefs.js'
|
import { getViewMode, setViewMode } from './prefs.js'
|
||||||
|
import { parseTimePrefix, splitTitle } from './quickAdd.js'
|
||||||
import ListView from './ListView.jsx'
|
import ListView from './ListView.jsx'
|
||||||
import BoardView from './BoardView.jsx'
|
import BoardView from './BoardView.jsx'
|
||||||
import TaskDetail from './TaskDetail.jsx'
|
import TaskDetail from './TaskDetail.jsx'
|
||||||
@@ -102,6 +103,33 @@ export default function ProjectView({ projectId, onProjectChanged, onProjectExit
|
|||||||
} catch { /* 已回滚并提示 */ }
|
} catch { /* 已回滚并提示 */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 组内拖动排序: 按分组顺序展平重写 sortOrder (只动顶层任务) */
|
||||||
|
const reorderTask = async (task, targetId, after) => {
|
||||||
|
const snapshot = tasks
|
||||||
|
const orderedCols = [...(columns || [])].sort((a, b) => a.sortOrder - b.sortOrder)
|
||||||
|
const groups = orderedCols.map((c) => (tasks || []).filter((t) => t.columnId === c.id)).filter((l) => l.length)
|
||||||
|
const g = groups.find((l) => l.some((t) => t.id === task.id))
|
||||||
|
if (!g) return
|
||||||
|
const rest = g.filter((t) => t.id !== task.id)
|
||||||
|
const ti = rest.findIndex((t) => t.id === targetId)
|
||||||
|
if (ti < 0) return
|
||||||
|
rest.splice(after ? ti + 1 : ti, 0, task)
|
||||||
|
const flat = groups.map((l) => (l === g ? rest : l)).flat()
|
||||||
|
const flatIds = new Set(flat.map((t) => t.id))
|
||||||
|
// 乐观更新必须重排数组本身 —— 列表分组渲染按数组顺序,
|
||||||
|
// 只改 sortOrder 字段界面不会动 (打卡侧即按此口径)
|
||||||
|
setTasks((prev) => [
|
||||||
|
...flat.map((t, i) => ({ ...t, sortOrder: i })),
|
||||||
|
...(prev || []).filter((t) => !flatIds.has(t.id)),
|
||||||
|
])
|
||||||
|
try {
|
||||||
|
await taskApi.reorder(projectId, flat.map((t) => t.id))
|
||||||
|
} catch (e) {
|
||||||
|
setTasks(snapshot)
|
||||||
|
Toast.show({ content: e.message || '排序保存失败' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** 改状态 (T-BD-14~16) */
|
/** 改状态 (T-BD-14~16) */
|
||||||
const changeStatus = async (task, status) => {
|
const changeStatus = async (task, status) => {
|
||||||
try {
|
try {
|
||||||
@@ -114,12 +142,21 @@ export default function ProjectView({ projectId, onProjectChanged, onProjectExit
|
|||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 新建任务 */
|
/** 新建任务 (快速添加): 时间词前缀 → 时间标记; 超过 30 字在分隔符处截断,
|
||||||
async function createTask(title, parentId = null) {
|
* 前段为标题, 余文并入描述 (T-TK-14/16) */
|
||||||
const t = String(title || '').trim()
|
async function createTask(rawTitle, parentId = null) {
|
||||||
if (!t) return
|
const raw = String(rawTitle || '').trim()
|
||||||
|
if (!raw) return
|
||||||
|
const { markDate, rest } = parseTimePrefix(raw)
|
||||||
|
const { title, desc } = splitTitle(rest)
|
||||||
|
if (!title) return
|
||||||
try {
|
try {
|
||||||
await taskApi.create(projectId, { title: t, parentId })
|
await taskApi.create(projectId, {
|
||||||
|
title,
|
||||||
|
description: desc || undefined,
|
||||||
|
markDate: markDate || undefined,
|
||||||
|
parentId,
|
||||||
|
})
|
||||||
setAdding(false)
|
setAdding(false)
|
||||||
load()
|
load()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -127,24 +164,9 @@ export default function ProjectView({ projectId, onProjectChanged, onProjectExit
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 长按任务 → 移动到列 (重点设计 §7.1: 列表页不做自由拖动, 菜单可靠) */
|
/** 任务 ⋯ 菜单: 编辑 / 拷贝到其他项目 / 删除 (点选后菜单收回) */
|
||||||
function openMoveMenu(task) {
|
|
||||||
ActionSheet.show({
|
|
||||||
actions: (columns || []).map((c) => ({
|
|
||||||
text: c.name + (c.id === task.columnId ? ' ✓' : ''),
|
|
||||||
key: String(c.id),
|
|
||||||
})),
|
|
||||||
cancelText: '取消',
|
|
||||||
onAction: (item) => {
|
|
||||||
const columnId = Number(item.key)
|
|
||||||
if (columnId !== task.columnId) moveToColumn(task, columnId)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 任务 ⋯ 菜单: 编辑 / 拷贝到其他项目 / 删除 */
|
|
||||||
function openTaskMenu(task) {
|
function openTaskMenu(task) {
|
||||||
ActionSheet.show({
|
const handler = ActionSheet.show({
|
||||||
actions: [
|
actions: [
|
||||||
{ text: '编辑', key: 'edit' },
|
{ text: '编辑', key: 'edit' },
|
||||||
{ text: '拷贝到其他项目', key: 'copy' },
|
{ text: '拷贝到其他项目', key: 'copy' },
|
||||||
@@ -152,6 +174,7 @@ export default function ProjectView({ projectId, onProjectChanged, onProjectExit
|
|||||||
],
|
],
|
||||||
cancelText: '取消',
|
cancelText: '取消',
|
||||||
onAction: async (item) => {
|
onAction: async (item) => {
|
||||||
|
handler.close?.()
|
||||||
if (item.key === 'edit') setOpenTask(task)
|
if (item.key === 'edit') setOpenTask(task)
|
||||||
else if (item.key === 'copy') setCopyTask(task)
|
else if (item.key === 'copy') setCopyTask(task)
|
||||||
else if (item.key === 'delete') {
|
else if (item.key === 'delete') {
|
||||||
@@ -217,12 +240,15 @@ export default function ProjectView({ projectId, onProjectChanged, onProjectExit
|
|||||||
|
|
||||||
{adding && (
|
{adding && (
|
||||||
<div className="quick-add">
|
<div className="quick-add">
|
||||||
<input
|
<textarea
|
||||||
className="inp"
|
className="inp"
|
||||||
autoFocus
|
autoFocus
|
||||||
placeholder="输入任务标题,回车创建"
|
rows={3}
|
||||||
|
maxLength={200}
|
||||||
|
placeholder={'输入任务标题,回车创建;开头可用 今天/明天/本周/本月 等时间词\n超过 30 字自动截断为标题,全文进描述'}
|
||||||
|
onBlur={(e) => { if (!e.currentTarget.value.trim()) setAdding(false) }}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === 'Enter') createTask(e.currentTarget.value)
|
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); createTask(e.currentTarget.value) }
|
||||||
if (e.key === 'Escape') setAdding(false)
|
if (e.key === 'Escape') setAdding(false)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -236,7 +262,8 @@ export default function ProjectView({ projectId, onProjectChanged, onProjectExit
|
|||||||
onToggle={toggleDone}
|
onToggle={toggleDone}
|
||||||
onOpen={(t) => setOpenTask(t)}
|
onOpen={(t) => setOpenTask(t)}
|
||||||
onMore={openTaskMenu}
|
onMore={openTaskMenu}
|
||||||
onLongPress={openMoveMenu}
|
onMoveColumn={moveToColumn}
|
||||||
|
onReorder={reorderTask}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<BoardView
|
<BoardView
|
||||||
@@ -258,6 +285,7 @@ export default function ProjectView({ projectId, onProjectChanged, onProjectExit
|
|||||||
columns={columns}
|
columns={columns}
|
||||||
members={project.members || []}
|
members={project.members || []}
|
||||||
tasks={tasks || []}
|
tasks={tasks || []}
|
||||||
|
taskKinds={project.taskKinds}
|
||||||
onClose={() => setOpenTask(null)}
|
onClose={() => setOpenTask(null)}
|
||||||
onSaved={() => { setOpenTask(null); load() }}
|
onSaved={() => { setOpenTask(null); load() }}
|
||||||
onChanged={load}
|
onChanged={load}
|
||||||
|
|||||||
@@ -1,30 +1,38 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { ActionSheet, Popup, Toast } from 'antd-mobile'
|
import { ActionSheet, Popup, Toast, DatePicker } from 'antd-mobile'
|
||||||
import { taskApi } from '../../api/task.js'
|
import { taskApi } from '../../api/task.js'
|
||||||
import { STATUS_OPTIONS, STATUS_LABEL } from './date.js'
|
import { STATUS_OPTIONS, STATUS_LABEL } from './date.js'
|
||||||
|
import { kindColor } from './kind.js'
|
||||||
|
import { parseTimePrefix, splitTitle } from './quickAdd.js'
|
||||||
import UserPicker from '../../components/task/UserPicker.jsx'
|
import UserPicker from '../../components/task/UserPicker.jsx'
|
||||||
import StatusDot from '../../components/task/StatusDot.jsx'
|
import StatusDot from '../../components/task/StatusDot.jsx'
|
||||||
|
import useKeyboardInset from '../../hooks/useKeyboardInset.js'
|
||||||
import useOverlayBack from '../../hooks/useOverlayBack.js'
|
import useOverlayBack from '../../hooks/useOverlayBack.js'
|
||||||
|
|
||||||
// 任务详情 (T-TK-*, T-ST-03, T-AS-05)
|
// 任务详情 (T-TK-*, T-ST-03, T-AS-05)
|
||||||
// - 状态: 三段式分段控件, 当前项高亮, 点按即切换
|
// - 状态: 三段式分段控件, 当前项高亮, 点按即切换
|
||||||
// - 看板列: ActionSheet 选择, 列/状态联动由服务端裁决 (T-BD-*)
|
// - 看板列: ActionSheet 选择, 列/状态联动由服务端裁决 (T-BD-*)
|
||||||
// - 转为子任务 / 转为顶层任务 (T-ST-12): 从项目内顶层任务中选父任务
|
// - 转为子任务 / 转为顶层任务 (T-ST-12): 从项目内顶层任务中选父任务
|
||||||
export default function TaskDetail({ task, columns, members, tasks, onClose, onSaved, onChanged }) {
|
export default function TaskDetail({ task, columns, members, tasks, taskKinds, onClose, onSaved, onChanged }) {
|
||||||
const [form, setForm] = useState(() => ({
|
const [form, setForm] = useState(() => ({
|
||||||
title: task.title || '',
|
title: task.title || '',
|
||||||
description: task.description || '',
|
description: task.description || '',
|
||||||
memberIds: (task.members || []).map((m) => m.userId),
|
memberIds: (task.members || []).map((m) => m.userId),
|
||||||
assigneeId: task.assignee?.id ?? null,
|
assigneeId: task.assignee?.id ?? null,
|
||||||
status: task.status,
|
status: task.status,
|
||||||
|
kind: task.kind || null,
|
||||||
|
markDate: task.markDate ? task.markDate.slice(0, 10) : null,
|
||||||
tags: task.tags || [],
|
tags: task.tags || [],
|
||||||
}))
|
}))
|
||||||
const [subs, setSubs] = useState(task.children || [])
|
const [subs, setSubs] = useState(task.children || [])
|
||||||
const [newSub, setNewSub] = useState('')
|
const [newSub, setNewSub] = useState('')
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [picker, setPicker] = useState(null) // 'assignee' | 'members'
|
const [picker, setPicker] = useState(null) // 'assignee' | 'members'
|
||||||
|
const [pickDate, setPickDate] = useState(false) // 时间标记日期选择
|
||||||
const [parentPick, setParentPick] = useState(false) // 转为子任务: 选择父任务
|
const [parentPick, setParentPick] = useState(false) // 转为子任务: 选择父任务
|
||||||
|
|
||||||
|
// 键盘弹出插入量: 抬升弹层内容, 输入框不被遮挡
|
||||||
|
const kb = useKeyboardInset()
|
||||||
// 返回键: 先关详情弹层, 不离开任务页
|
// 返回键: 先关详情弹层, 不离开任务页
|
||||||
useOverlayBack(true, onClose)
|
useOverlayBack(true, onClose)
|
||||||
|
|
||||||
@@ -44,6 +52,8 @@ export default function TaskDetail({ task, columns, members, tasks, onClose, onS
|
|||||||
memberIds: (task.members || []).map((m) => m.userId),
|
memberIds: (task.members || []).map((m) => m.userId),
|
||||||
assigneeId: task.assignee?.id ?? null,
|
assigneeId: task.assignee?.id ?? null,
|
||||||
status: task.status,
|
status: task.status,
|
||||||
|
kind: task.kind || null,
|
||||||
|
markDate: task.markDate ? task.markDate.slice(0, 10) : null,
|
||||||
tags: task.tags || [],
|
tags: task.tags || [],
|
||||||
})
|
})
|
||||||
setSubs(task.children || [])
|
setSubs(task.children || [])
|
||||||
@@ -59,6 +69,8 @@ export default function TaskDetail({ task, columns, members, tasks, onClose, onS
|
|||||||
assigneeId: form.assigneeId,
|
assigneeId: form.assigneeId,
|
||||||
memberIds: form.memberIds,
|
memberIds: form.memberIds,
|
||||||
status: form.status,
|
status: form.status,
|
||||||
|
kind: form.kind,
|
||||||
|
markDate: form.markDate || null,
|
||||||
tags: form.tags,
|
tags: form.tags,
|
||||||
})
|
})
|
||||||
Toast.show({ content: '已保存' })
|
Toast.show({ content: '已保存' })
|
||||||
@@ -104,10 +116,19 @@ export default function TaskDetail({ task, columns, members, tasks, onClose, onS
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function addSub() {
|
async function addSub() {
|
||||||
const t = newSub.trim()
|
// 子任务快速添加同样支持时间词前缀 + 30 字分隔符截断 (T-TK-14/16)
|
||||||
if (!t) return
|
const raw = newSub.trim()
|
||||||
|
if (!raw) return
|
||||||
|
const { markDate, rest } = parseTimePrefix(raw)
|
||||||
|
const { title, desc } = splitTitle(rest)
|
||||||
|
if (!title) return
|
||||||
try {
|
try {
|
||||||
const r = await taskApi.create(task.projectId, { title: t, parentId: task.id })
|
const r = await taskApi.create(task.projectId, {
|
||||||
|
title,
|
||||||
|
description: desc || undefined,
|
||||||
|
markDate: markDate || undefined,
|
||||||
|
parentId: task.id,
|
||||||
|
})
|
||||||
setSubs((prev) => [...prev, r.item])
|
setSubs((prev) => [...prev, r.item])
|
||||||
setNewSub('')
|
setNewSub('')
|
||||||
onChanged?.()
|
onChanged?.()
|
||||||
@@ -127,12 +148,17 @@ export default function TaskDetail({ task, columns, members, tasks, onClose, onS
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return ( <Popup
|
||||||
<Popup
|
|
||||||
visible
|
visible
|
||||||
onMaskClick={onClose}
|
onMaskClick={onClose}
|
||||||
position="bottom"
|
position="bottom"
|
||||||
className="kb-safe"
|
bodyStyle={{
|
||||||
|
borderTopLeftRadius: 12,
|
||||||
|
borderTopRightRadius: 12,
|
||||||
|
maxHeight: `calc(88vh - ${kb}px)`,
|
||||||
|
overflowY: 'auto',
|
||||||
|
padding: `16px 16px calc(env(safe-area-inset-bottom, 0px) + 20px + ${kb}px)`,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div className="sheet-title">{task.title}</div>
|
<div className="sheet-title">{task.title}</div>
|
||||||
<div className="sheet-sub">
|
<div className="sheet-sub">
|
||||||
@@ -144,7 +170,7 @@ export default function TaskDetail({ task, columns, members, tasks, onClose, onS
|
|||||||
<input
|
<input
|
||||||
className="inp"
|
className="inp"
|
||||||
value={form.title}
|
value={form.title}
|
||||||
maxLength={200}
|
maxLength={30}
|
||||||
onChange={(e) => setForm((p) => ({ ...p, title: e.target.value }))}
|
onChange={(e) => setForm((p) => ({ ...p, title: e.target.value }))}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -176,6 +202,61 @@ export default function TaskDetail({ task, columns, members, tasks, onClose, onS
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 时间标记: 可选日期, 卡片上以 MM-dd 显示 */}
|
||||||
|
<div className="field">
|
||||||
|
<label>时间标记</label>
|
||||||
|
<div className="pick-row">
|
||||||
|
<span className="pn">{form.markDate ? form.markDate.slice(5).replace('-', '/') : '未设置'}</span>
|
||||||
|
{form.markDate && (
|
||||||
|
<span className="link" onClick={() => setForm((p) => ({ ...p, markDate: null }))}>清除</span>
|
||||||
|
)}
|
||||||
|
<span className="link" onClick={() => setPickDate(true)}>选择 ›</span>
|
||||||
|
</div>
|
||||||
|
<DatePicker
|
||||||
|
visible={pickDate}
|
||||||
|
onClose={() => setPickDate(false)}
|
||||||
|
precision="day"
|
||||||
|
value={form.markDate ? new Date(form.markDate + 'T00:00:00') : new Date()}
|
||||||
|
onConfirm={(d) => {
|
||||||
|
const p2 = (n) => String(n).padStart(2, '0')
|
||||||
|
setForm((p) => ({ ...p, markDate: `${d.getFullYear()}-${p2(d.getMonth() + 1)}-${p2(d.getDate())}` }))
|
||||||
|
setPickDate(false)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 类型: 项目任务类型标签, 可清空 */}
|
||||||
|
{(taskKinds || []).length > 0 && (
|
||||||
|
<div className="field">
|
||||||
|
<label>类型</label>
|
||||||
|
<div className="kind-chip-row" style={{ flexWrap: 'wrap' }}>
|
||||||
|
<span
|
||||||
|
className="kind-tag"
|
||||||
|
style={{
|
||||||
|
background: form.kind ? 'transparent' : 'var(--color-primary)',
|
||||||
|
color: form.kind ? '#999' : '#fff',
|
||||||
|
border: form.kind ? '1px dashed #ccc' : 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
onClick={() => setForm((p) => ({ ...p, kind: null }))}
|
||||||
|
>无</span>
|
||||||
|
{taskKinds.map((k) => (
|
||||||
|
<span
|
||||||
|
key={k}
|
||||||
|
className="kind-tag"
|
||||||
|
style={{
|
||||||
|
background: kindColor(k),
|
||||||
|
opacity: form.kind === k ? 1 : 0.45,
|
||||||
|
cursor: 'pointer',
|
||||||
|
outline: form.kind === k ? '2px solid rgba(22,119,255,.4)' : 'none',
|
||||||
|
}}
|
||||||
|
onClick={() => setForm((p) => ({ ...p, kind: p.kind === k ? null : k }))}
|
||||||
|
>{k}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 看板列: 点击换列, 状态随目标列联动 */}
|
{/* 看板列: 点击换列, 状态随目标列联动 */}
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<label>看板列</label>
|
<label>看板列</label>
|
||||||
@@ -273,8 +354,11 @@ export default function TaskDetail({ task, columns, members, tasks, onClose, onS
|
|||||||
<Popup
|
<Popup
|
||||||
visible={parentPick}
|
visible={parentPick}
|
||||||
onMaskClick={() => setParentPick(false)}
|
onMaskClick={() => setParentPick(false)}
|
||||||
className="kb-safe"
|
bodyStyle={{
|
||||||
bodyStyle={{ maxHeight: '70vh' }}
|
borderTopLeftRadius: 12, borderTopRightRadius: 12,
|
||||||
|
maxHeight: '70vh', overflowY: 'auto',
|
||||||
|
padding: '16px 16px calc(env(safe-area-inset-bottom, 0px) + 20px)',
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div className="sheet-title">转为子任务</div>
|
<div className="sheet-title">转为子任务</div>
|
||||||
<div className="sheet-sub">选择父任务(仅支持一级;已有子任务的任务不能作为父任务)</div>
|
<div className="sheet-sub">选择父任务(仅支持一级;已有子任务的任务不能作为父任务)</div>
|
||||||
|
|||||||
@@ -126,8 +126,7 @@ export default function TaskPage() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const COLORS = ['#1677ff', '#00b578', '#ff8f1f', '#a855f7', '#14b8a6', '#f43f9e']
|
import { colorOfProject } from './projectIcon.js'
|
||||||
const colorOf = (p) => COLORS[Math.abs(p.id) % COLORS.length]
|
|
||||||
const LONG_PRESS = 300
|
const LONG_PRESS = 300
|
||||||
const MOVE_TOLERANCE = 8
|
const MOVE_TOLERANCE = 8
|
||||||
|
|
||||||
@@ -286,8 +285,13 @@ function ProjectList({ projects, err, onRetry, onOpen, onChanged, createSignal,
|
|||||||
onMouseDown={(ev) => beginPress(p, ev)}
|
onMouseDown={(ev) => beginPress(p, ev)}
|
||||||
onClick={() => { if (!activeRef.current && !dragId) onOpen(p.id) }}
|
onClick={() => { if (!activeRef.current && !dragId) onOpen(p.id) }}
|
||||||
>
|
>
|
||||||
<div className="pico" style={{ background: colorOf(p) }}>
|
<div
|
||||||
{(p.name || '?').slice(0, 1)}
|
className="pico"
|
||||||
|
style={p.icon
|
||||||
|
? { background: `url(${p.icon}) center/cover no-repeat` }
|
||||||
|
: { background: colorOfProject(p) }}
|
||||||
|
>
|
||||||
|
{p.icon ? '' : (p.name || '?').slice(0, 1)}
|
||||||
</div>
|
</div>
|
||||||
<div className="pmain">
|
<div className="pmain">
|
||||||
<div className="ptitle">
|
<div className="ptitle">
|
||||||
@@ -315,7 +319,14 @@ function ProjectList({ projects, err, onRetry, onOpen, onChanged, createSignal,
|
|||||||
<div className="proj-group">已归档({archived.length})</div>
|
<div className="proj-group">已归档({archived.length})</div>
|
||||||
{archived.map((p) => (
|
{archived.map((p) => (
|
||||||
<div className="proj-item" key={p.id} style={{ opacity: 0.65 }} onClick={() => onOpen(p.id)}>
|
<div className="proj-item" key={p.id} style={{ opacity: 0.65 }} onClick={() => onOpen(p.id)}>
|
||||||
<div className="pico" style={{ background: '#a8b0ba' }}>{(p.name || '?').slice(0, 1)}</div>
|
<div
|
||||||
|
className="pico"
|
||||||
|
style={p.icon
|
||||||
|
? { background: `url(${p.icon}) center/cover no-repeat` }
|
||||||
|
: { background: '#a8b0ba' }}
|
||||||
|
>
|
||||||
|
{p.icon ? '' : (p.name || '?').slice(0, 1)}
|
||||||
|
</div>
|
||||||
<div className="pmain">
|
<div className="pmain">
|
||||||
<div className="ptitle">{p.name}</div>
|
<div className="ptitle">{p.name}</div>
|
||||||
<div className="psub">已归档 · {p.taskCount ?? 0} 个任务</div>
|
<div className="psub">已归档 · {p.taskCount ?? 0} 个任务</div>
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
}
|
||||||
+170
-37
@@ -23,6 +23,13 @@
|
|||||||
|
|
||||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
|
||||||
|
/* 全局禁用非编辑类文字的选中/长按菜单 (输入框除外);
|
||||||
|
各页面原有的局部规则保留但不再必要 */
|
||||||
|
* { -webkit-user-select: none; user-select: none; -webkit-touch-callout: none; }
|
||||||
|
input, textarea, [contenteditable="true"], [contenteditable=""] {
|
||||||
|
-webkit-user-select: text; user-select: text; -webkit-touch-callout: default;
|
||||||
|
}
|
||||||
|
|
||||||
html, body, #root {
|
html, body, #root {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background: var(--color-bg);
|
background: var(--color-bg);
|
||||||
@@ -72,6 +79,11 @@ input, textarea, select { font: inherit; }
|
|||||||
-webkit-overflow-scrolling: touch;
|
-webkit-overflow-scrolling: touch;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 页面容器: 高度打通 app-main → page-with-navbar 的百分比链。
|
||||||
|
没有这行 height:100%, page-with-navbar 的 height:100% 对 auto 高度的父级失效,
|
||||||
|
整页塌成内容高 —— 看板 board-wrap 随之只卡片区那么高, 横滑只在有任务的区域生效 */
|
||||||
|
.page-transition { height: 100%; }
|
||||||
|
|
||||||
/* 页面切换动画: pathname 变化时整页淡入 + 轻微上移 (由 MobileShell 控制 class) */
|
/* 页面切换动画: pathname 变化时整页淡入 + 轻微上移 (由 MobileShell 控制 class) */
|
||||||
.page-transition.active {
|
.page-transition.active {
|
||||||
animation: page-in 0.28s ease;
|
animation: page-in 0.28s ease;
|
||||||
@@ -658,6 +670,77 @@ body.me-safe-top .app-safe-top { height: 0; }
|
|||||||
.habit-link { font-size: 12px; color: var(--color-primary); cursor: pointer; }
|
.habit-link { font-size: 12px; color: var(--color-primary); cursor: pointer; }
|
||||||
.habit-link.danger { color: var(--color-danger); }
|
.habit-link.danger { color: var(--color-danger); }
|
||||||
|
|
||||||
|
/* 打卡项分组 + 长按拖动排序 (组内) */
|
||||||
|
.habit-view { -webkit-user-select: none; user-select: none; -webkit-touch-callout: none; }
|
||||||
|
.habit-view input, .habit-view textarea { -webkit-user-select: text; user-select: text; }
|
||||||
|
.habit-group-title {
|
||||||
|
display: flex; align-items: center; gap: 7px; padding: 10px 2px 4px;
|
||||||
|
font-size: 12px; color: var(--color-muted); font-weight: 600;
|
||||||
|
}
|
||||||
|
.habit-group-title::before { content: ""; width: 3px; height: 12px; border-radius: 2px; background: var(--color-primary); }
|
||||||
|
.habit-group-title .cnt { color: #b8bcc4; font-weight: 400; }
|
||||||
|
.habit-card { touch-action: pan-y; }
|
||||||
|
.habit-card.dragging { opacity: 0.55; outline: 2px dashed var(--color-primary); outline-offset: 1px; }
|
||||||
|
.habit-card.dragover { outline: 2px solid var(--color-primary); outline-offset: 1px; }
|
||||||
|
.habit-card.dragover.after { border-bottom: 3px solid var(--color-primary); }
|
||||||
|
.habit-card.dragover.before { border-top: 3px solid var(--color-primary); }
|
||||||
|
|
||||||
|
/* 拖动把手 (⠿): touch-action:none —— 浏览器完全不接管该触摸,
|
||||||
|
指针流不会被滚动抢占取消, 是 Android WebView 上唯一可靠的拖动入口 */
|
||||||
|
.grip {
|
||||||
|
touch-action: none; -webkit-user-select: none; user-select: none;
|
||||||
|
color: #c2c7cf; font-size: 10px; line-height: 1;
|
||||||
|
padding: 10px 1px; margin: -6px -2px; min-width: 8px; min-height: 26px;
|
||||||
|
display: inline-flex; align-items: center; justify-content: center;
|
||||||
|
cursor: grab; flex-shrink: 0; align-self: center;
|
||||||
|
}
|
||||||
|
.grip:active { color: var(--color-primary); }
|
||||||
|
.task-right .grip { margin-right: 2px; }
|
||||||
|
|
||||||
|
/* MergedView 打卡卡拖动排序 */
|
||||||
|
.mv-view { -webkit-user-select: none; user-select: none; -webkit-touch-callout: none; }
|
||||||
|
.mv-view input, .mv-view textarea { -webkit-user-select: text; user-select: text; }
|
||||||
|
.mv-card { touch-action: pan-y; }
|
||||||
|
.mv-card.dragging { opacity: .3; outline: 2px dashed var(--color-primary); outline-offset: 1px; }
|
||||||
|
.mv-card.dragover { outline: 2px solid var(--color-primary); outline-offset: 1px; }
|
||||||
|
.mv-card.dragover.after { border-bottom: 3px solid var(--color-primary); }
|
||||||
|
.mv-card.dragover.before { border-top: 3px solid var(--color-primary); }
|
||||||
|
|
||||||
|
/* 任务类型小标签: 列表行左下 / 看板卡左下 */
|
||||||
|
.date-tag {
|
||||||
|
display: inline-flex; align-items: center;
|
||||||
|
font-size: 10px; line-height: 1; font-weight: 600;
|
||||||
|
color: #ad6800; background: #fff7e6; border-radius: 4px; padding: 3px 5px;
|
||||||
|
flex-shrink: 0; white-space: nowrap; font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.kind-tag {
|
||||||
|
display: inline-flex; align-items: center;
|
||||||
|
font-size: 10px; line-height: 1; font-weight: 600; color: #fff;
|
||||||
|
border-radius: 4px; padding: 3px 5px; flex-shrink: 0; white-space: nowrap;
|
||||||
|
}
|
||||||
|
/* 项目管理: 任务类型编辑 chips */
|
||||||
|
.kind-chip-row { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||||
|
.kind-chip {
|
||||||
|
display: inline-flex; align-items: center; gap: 5px;
|
||||||
|
font-size: 12.5px; color: #fff; border-radius: 999px; padding: 5px 6px 5px 11px;
|
||||||
|
}
|
||||||
|
.kind-chip .x { font-size: 13px; color: rgba(255,255,255,.75); cursor: pointer; padding: 0 4px; }
|
||||||
|
|
||||||
|
/* 拖动跟手浮层: 克隆原卡片淡化后挂在 body 顶层, 跟随指针 */
|
||||||
|
.drag-ghost {
|
||||||
|
position: fixed; left: 0; top: 0; z-index: 1000;
|
||||||
|
pointer-events: none; will-change: transform;
|
||||||
|
background: transparent; padding: 0; margin: 0; max-width: none; border-radius: 0;
|
||||||
|
-webkit-user-select: none; user-select: none; -webkit-touch-callout: none;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
}
|
||||||
|
.drag-ghost * { -webkit-tap-highlight-color: transparent; }
|
||||||
|
.drag-ghost > * {
|
||||||
|
margin: 0 !important; pointer-events: none;
|
||||||
|
opacity: .82; border-radius: 9px;
|
||||||
|
box-shadow: 0 12px 28px rgba(15,23,42,.28);
|
||||||
|
}
|
||||||
|
|
||||||
/* ----- 日历视图 ----- */
|
/* ----- 日历视图 ----- */
|
||||||
.calendar-view { padding: 0 12px 20px; }
|
.calendar-view { padding: 0 12px 20px; }
|
||||||
.cal-month-bar { display: flex; align-items: center; gap: 10px; padding: 8px 2px; }
|
.cal-month-bar { display: flex; align-items: center; gap: 10px; padding: 8px 2px; }
|
||||||
@@ -902,8 +985,17 @@ body.me-safe-top .app-safe-top { height: 0; }
|
|||||||
width: 34px; height: 34px; border-radius: 9px; display: flex; align-items: center;
|
width: 34px; height: 34px; border-radius: 9px; display: flex; align-items: center;
|
||||||
justify-content: center; color: #fff; font-size: 14px; font-weight: 700; flex-shrink: 0;
|
justify-content: center; color: #fff; font-size: 14px; font-weight: 700; flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
/* 项目头像基类 (不依赖 .proj-item 作用域): 项目管理编辑页预览等裸用场景 */
|
||||||
|
.pavatar {
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
color: #fff; font-weight: 700; flex-shrink: 0;
|
||||||
|
background-size: cover; background-position: center; background-repeat: no-repeat;
|
||||||
|
}
|
||||||
.proj-item .pmain { flex: 1; min-width: 0; }
|
.proj-item .pmain { flex: 1; min-width: 0; }
|
||||||
.proj-item .ptitle { font-size: 14.5px; font-weight: 600; display: flex; align-items: center; gap: 6px; }
|
.proj-item .ptitle {
|
||||||
|
font-size: 14.5px; font-weight: 600; display: flex; align-items: center; gap: 6px;
|
||||||
|
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||||
|
}
|
||||||
.proj-item .psub { font-size: 11.5px; color: var(--color-muted); margin-top: 2px; }
|
.proj-item .psub { font-size: 11.5px; color: var(--color-muted); margin-top: 2px; }
|
||||||
.proj-item .pcount { font-size: 15px; color: #c0c4cc; flex-shrink: 0; }
|
.proj-item .pcount { font-size: 15px; color: #c0c4cc; flex-shrink: 0; }
|
||||||
.badge { font-size: 10px; color: #ad6800; background: #fff7e6; border-radius: 4px; padding: 0 5px; font-weight: 400; }
|
.badge { font-size: 10px; color: #ad6800; background: #fff7e6; border-radius: 4px; padding: 0 5px; font-weight: 400; }
|
||||||
@@ -942,21 +1034,30 @@ body.me-safe-top .app-safe-top { height: 0; }
|
|||||||
.task-main { flex: 1; min-width: 0; }
|
.task-main { flex: 1; min-width: 0; }
|
||||||
.task-title { font-size: 14px; display: flex; align-items: center; gap: 6px; word-break: break-word; }
|
.task-title { font-size: 14px; display: flex; align-items: center; gap: 6px; word-break: break-word; }
|
||||||
.task-row.done .task-title { color: #a0a0a0; text-decoration: line-through; }
|
.task-row.done .task-title { color: #a0a0a0; text-decoration: line-through; }
|
||||||
.task-meta { display: flex; align-items: center; gap: 7px; margin-top: 4px; font-size: 11px; color: #999; flex-wrap: wrap; }
|
.task-meta {
|
||||||
.task-meta .col { color: var(--color-primary); background: #f0f5ff; border-radius: 4px; padding: 0 5px; }
|
display: flex; align-items: center; gap: 7px; margin-top: 4px; font-size: 11px;
|
||||||
|
color: #999; flex-wrap: wrap; min-height: 17px;
|
||||||
|
}
|
||||||
.task-meta .assignee { font-size: 12px; font-weight: 600; color: var(--color-text); }
|
.task-meta .assignee { font-size: 12px; font-weight: 600; color: var(--color-text); }
|
||||||
.task-meta .assignee.none { color: #b8bcc4; font-weight: 400; }
|
.task-meta .assignee.none { color: #b8bcc4; font-weight: 400; }
|
||||||
.task-meta .members { font-size: 10.5px; color: #a7adb6; }
|
.task-meta .members { font-size: 10.5px; color: #a7adb6; }
|
||||||
.task-right { display: flex; align-items: center; flex-shrink: 0; }
|
.task-right {
|
||||||
|
display: flex; flex-direction: column; align-items: flex-end; justify-content: space-between;
|
||||||
|
align-self: stretch; flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.task-right .assignee { font-size: 12px; font-weight: 600; color: var(--color-text); }
|
||||||
|
.task-right .assignee.none { color: #b8bcc4; font-weight: 400; }
|
||||||
.more { color: #c2c7cf; font-size: 16px; line-height: 1; padding: 0 2px; cursor: pointer; }
|
.more { color: #c2c7cf; font-size: 16px; line-height: 1; padding: 0 2px; cursor: pointer; }
|
||||||
.sub-row {
|
.sub-row {
|
||||||
display: flex; align-items: center; gap: 8px; background: #fff; border-radius: 8px;
|
display: flex; align-items: flex-start; gap: 8px; background: #fff; border-radius: 8px;
|
||||||
padding: 8px 12px 8px 30px; margin: -2px 12px 7px 26px; box-shadow: 0 1px 2px rgba(0,0,0,.03);
|
padding: 8px 12px 8px 30px; margin: -2px 12px 7px 26px; box-shadow: 0 1px 2px rgba(0,0,0,.03);
|
||||||
position: relative; cursor: pointer;
|
position: relative; cursor: pointer;
|
||||||
}
|
}
|
||||||
.sub-row::before { content: ""; position: absolute; left: 14px; top: 0; bottom: 0; width: 1px; background: #e9ecf0; }
|
.sub-row::before { content: ""; position: absolute; left: 14px; top: 0; bottom: 0; width: 1px; background: #e9ecf0; }
|
||||||
.sub-row .box { width: 16px; height: 16px; }
|
.sub-row .box { width: 16px; height: 16px; margin-top: 2px; }
|
||||||
.sub-title { flex: 1; font-size: 13px; color: #444; }
|
.sub-main { flex: 1; min-width: 0; }
|
||||||
|
.sub-title { display: block; font-size: 13px; color: #444; }
|
||||||
|
.sub-row .task-meta { font-size: 10.5px; margin-top: 2px; gap: 5px; }
|
||||||
.sub-row.done .sub-title { color: #a8a8a8; text-decoration: line-through; }
|
.sub-row.done .sub-title { color: #a8a8a8; text-decoration: line-through; }
|
||||||
.collapse { font-size: 11px; color: var(--color-primary); padding: 2px 0 8px 30px; margin: 0 12px; cursor: pointer; }
|
.collapse { font-size: 11px; color: var(--color-primary); padding: 2px 0 8px 30px; margin: 0 12px; cursor: pointer; }
|
||||||
.sub-add-hint { font-size: 11px; color: #b8bcc4; padding: 0 0 8px 44px; cursor: pointer; }
|
.sub-add-hint { font-size: 11px; color: #b8bcc4; padding: 0 0 8px 44px; cursor: pointer; }
|
||||||
@@ -976,25 +1077,45 @@ body.me-safe-top .app-safe-top { height: 0; }
|
|||||||
display: flex; gap: 10px; padding: 0 12px 8px; overflow-x: auto; overflow-y: hidden;
|
display: flex; gap: 10px; padding: 0 12px 8px; overflow-x: auto; overflow-y: hidden;
|
||||||
flex: 1 1 auto; min-height: 0; align-items: stretch;
|
flex: 1 1 auto; min-height: 0; align-items: stretch;
|
||||||
}
|
}
|
||||||
.bcol { flex: 0 0 186px; display: flex; flex-direction: column; min-height: 0; }
|
.bcol { flex: 0 0 186px; display: flex; flex-direction: column; min-height: 0; }.bcol-head { display: flex; align-items: center; gap: 5px; padding: 0 2px 7px; flex-shrink: 0; }
|
||||||
.bcol-head { display: flex; align-items: center; gap: 5px; padding: 0 2px 7px; flex-shrink: 0; }
|
.bcol-name { font-size: 12px; font-weight: 700; color: #3d4450; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
.bcol-name { font-size: 12px; font-weight: 700; color: #3d4450; }
|
|
||||||
.bcol-cnt { font-size: 11px; color: #999; background: #e8ebef; border-radius: 999px; padding: 0 6px; line-height: 15px; }
|
.bcol-cnt { font-size: 11px; color: #999; background: #e8ebef; border-radius: 999px; padding: 0 6px; line-height: 15px; }
|
||||||
.bcol-bind { font-size: 10px; color: var(--color-primary); background: #f0f5ff; border-radius: 4px; padding: 0 4px; margin-left: auto; }
|
.bcol-bind { font-size: 10px; color: var(--color-primary); background: #f0f5ff; border-radius: 4px; padding: 0 4px; margin-left: auto; }
|
||||||
.bcol-bind.off { color: #999; background: #f2f3f5; }
|
.bcol-bind.off { color: #999; background: #f2f3f5; }
|
||||||
.bcol-body { flex: 1 1 auto; min-height: 0; overflow-y: auto; display: flex; flex-direction: column; gap: 7px; padding-bottom: 8px; }
|
.bcol-body {
|
||||||
|
flex: 1 1 auto; min-height: 0; height: 100%; overflow-y: auto;
|
||||||
|
display: flex; flex-direction: column; gap: 7px; padding-bottom: 8px;
|
||||||
|
-webkit-overflow-scrolling: touch; overscroll-behavior: contain;
|
||||||
|
}
|
||||||
|
/* 列体包裹层: 承载右缘滚动条 (滚动时出现, 停止后淡出) */
|
||||||
|
.bcol-main { flex: 1 1 auto; min-height: 0; position: relative; display: flex; }
|
||||||
|
.bcol-bar {
|
||||||
|
position: absolute; right: 1px; width: 3px; border-radius: 2px;
|
||||||
|
background: rgba(15, 23, 42, .3); opacity: 0; transition: opacity .25s ease;
|
||||||
|
pointer-events: none; z-index: 3;
|
||||||
|
}
|
||||||
|
.bcol-bar.on { opacity: 1; }
|
||||||
.bcol.dragover .bcol-body { background: #eaf2ff; border-radius: 8px; outline: 1.5px dashed var(--color-primary); }
|
.bcol.dragover .bcol-body { background: #eaf2ff; border-radius: 8px; outline: 1.5px dashed var(--color-primary); }
|
||||||
.bcol-empty { font-size: 11px; color: #c0c4cc; text-align: center; padding: 10px 0; }
|
.bcol-empty { font-size: 11px; color: #c0c4cc; text-align: center; padding: 10px 0; }
|
||||||
.bcard {
|
.bcard {
|
||||||
background: #fff; border-radius: 8px; padding: 9px 10px;
|
background: #fff; border-radius: 8px; padding: 9px 10px;
|
||||||
box-shadow: 0 1px 2px rgba(0,0,0,.05); cursor: pointer; flex-shrink: 0;
|
box-shadow: 0 1px 2px rgba(0,0,0,.05); cursor: pointer; flex-shrink: 0;
|
||||||
|
display: flex; gap: 7px; align-items: flex-start;
|
||||||
}
|
}
|
||||||
.bcard.dragging { opacity: .55; transform: scale(.97) rotate(-1deg); box-shadow: 0 8px 18px rgba(15,23,42,.16); }
|
/* 左侧竖排操作列: 状态点在上, ⋯ 沉到左下角; 负外距悬进卡片内边距, 少占横向空间 */
|
||||||
|
.bcard-left {
|
||||||
|
display: flex; flex-direction: column; align-items: center; flex-shrink: 0;
|
||||||
|
align-self: stretch; justify-content: space-between; padding: 1px 0 2px;
|
||||||
|
margin: 0 -5px; width: 14px;
|
||||||
|
}
|
||||||
|
.bcard-left .bstat { padding: 0; }
|
||||||
|
.bcard-left .bmore-btn { font-size: 12px; padding: 0; line-height: 1.1; }
|
||||||
|
.bcard-body { flex: 1; min-width: 0; }
|
||||||
.bcard.hl { outline: 2px solid var(--color-primary); outline-offset: 1px; }
|
.bcard.hl { outline: 2px solid var(--color-primary); outline-offset: 1px; }
|
||||||
.brow1 { display: flex; align-items: flex-start; gap: 6px; }
|
.brow1 { display: flex; align-items: flex-start; gap: 6px; }
|
||||||
.btitle { flex: 1; font-size: 12.5px; font-weight: 600; line-height: 1.45; word-break: break-word; }
|
.btitle { flex: 1; font-size: 12.5px; font-weight: 600; line-height: 1.45; word-break: break-word; }
|
||||||
.bstat { display: flex; align-items: center; padding: 2px; cursor: pointer; }
|
.bstat { display: flex; align-items: center; padding: 2px; cursor: pointer; }
|
||||||
.bmeta { display: flex; align-items: center; gap: 6px; margin-top: 6px; font-size: 10.5px; }
|
.bmeta { display: flex; align-items: center; justify-content: flex-end; gap: 6px; margin-top: 6px; font-size: 10.5px; }
|
||||||
.bassignee { font-size: 11px; font-weight: 600; color: #4a5160; }
|
.bassignee { font-size: 11px; font-weight: 600; color: #4a5160; }
|
||||||
.bassignee.none { color: #b8bcc4; font-weight: 400; }
|
.bassignee.none { color: #b8bcc4; font-weight: 400; }
|
||||||
.bmembers { font-size: 10px; color: #a7adb6; }
|
.bmembers { font-size: 10px; color: #a7adb6; }
|
||||||
@@ -1012,18 +1133,16 @@ body.me-safe-top .app-safe-top { height: 0; }
|
|||||||
background: rgba(255,255,255,.6); border: 1px dashed #d5dae1; border-radius: 8px;
|
background: rgba(255,255,255,.6); border: 1px dashed #d5dae1; border-radius: 8px;
|
||||||
padding: 8px; text-align: center; font-size: 11.5px; color: #b8bcc4; cursor: pointer; flex-shrink: 0;
|
padding: 8px; text-align: center; font-size: 11.5px; color: #b8bcc4; cursor: pointer; flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.drag-ghost {
|
|
||||||
position: fixed; z-index: 999; transform: translate(-50%, -50%); pointer-events: none;
|
|
||||||
background: rgba(22,119,255,.92); color: #fff; font-size: 12px; padding: 6px 10px;
|
|
||||||
border-radius: 8px; max-width: 180px; box-shadow: 0 8px 18px rgba(15,23,42,.25);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---- 列设置 / 成员页 ---- */
|
/* ---- 列设置 / 成员页 ---- */
|
||||||
/* fixed 覆盖层要自己让出顶部状态栏 (复用 main.jsx 校准的 --safe-top), 否则与系统工具栏重合 */
|
/* fixed 覆盖层要自己让出顶部状态栏 (复用 main.jsx 校准的 --safe-top), 否则与系统工具栏重合 */
|
||||||
.cs-page { position: fixed; inset: 0; z-index: 1000; background: var(--color-bg); display: flex; flex-direction: column; padding-top: var(--safe-top); }
|
.cs-page { position: fixed; inset: 0; z-index: 1000; background: var(--color-bg); display: flex; flex-direction: column; padding-top: var(--safe-top); }
|
||||||
.cs-page .navbar-sub { display: flex; align-items: center; gap: 10px; padding: 12px 14px; background: #fff; border-bottom: 1px solid var(--color-border); flex-shrink: 0; }
|
.cs-page .navbar-sub { display: flex; align-items: center; gap: 10px; padding: 12px 14px; background: #fff; border-bottom: 1px solid var(--color-border); flex-shrink: 0; }
|
||||||
.cs-page .navbar-sub .back { font-size: 20px; color: #666; line-height: 1; cursor: pointer; }
|
.cs-page .navbar-sub .back { font-size: 20px; color: #666; line-height: 1; cursor: pointer; }
|
||||||
.cs-page .navbar-sub .title { font-size: 16px; font-weight: 700; }
|
.cs-page .navbar-sub .title {
|
||||||
|
font-size: 16px; font-weight: 700;
|
||||||
|
min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||||
|
}
|
||||||
.cs-page .navbar-sub .right { margin-left: auto; display: flex; gap: 6px; }
|
.cs-page .navbar-sub .right { margin-left: auto; display: flex; gap: 6px; }
|
||||||
.cs-body { flex: 1 1 auto; min-height: 0; overflow-y: auto; padding-bottom: 20px; }
|
.cs-body { flex: 1 1 auto; min-height: 0; overflow-y: auto; padding-bottom: 20px; }
|
||||||
.set-row {
|
.set-row {
|
||||||
@@ -1045,8 +1164,14 @@ body.me-safe-top .app-safe-top { height: 0; }
|
|||||||
.cs-count { font-size: 11px; color: #999; margin-left: 8px; }
|
.cs-count { font-size: 11px; color: #999; margin-left: 8px; }
|
||||||
|
|
||||||
/* ---- 任务详情 ---- */
|
/* ---- 任务详情 ---- */
|
||||||
.sheet-title { font-size: 16px; font-weight: 700; margin-bottom: 4px; word-break: break-word; }
|
.sheet-title {
|
||||||
.sheet-sub { font-size: 11.5px; color: #999; margin-bottom: 14px; }
|
font-size: 16px; font-weight: 700; margin-bottom: 4px; word-break: break-word;
|
||||||
|
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
|
||||||
|
}
|
||||||
|
.sheet-sub {
|
||||||
|
font-size: 11.5px; color: #999; margin-bottom: 14px;
|
||||||
|
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||||
|
}
|
||||||
.field { margin-bottom: 13px; }
|
.field { margin-bottom: 13px; }
|
||||||
.field label { display: block; font-size: 12px; color: var(--color-muted); margin-bottom: 6px; }
|
.field label { display: block; font-size: 12px; color: var(--color-muted); margin-bottom: 6px; }
|
||||||
.inp {
|
.inp {
|
||||||
@@ -1151,25 +1276,33 @@ textarea.inp { resize: vertical; }
|
|||||||
.seg div.on { background: #fff; color: var(--color-text); font-weight: 600; box-shadow: 0 1px 2px rgba(0,0,0,.06); }
|
.seg div.on { background: #fff; color: var(--color-text); font-weight: 600; box-shadow: 0 1px 2px rgba(0,0,0,.06); }
|
||||||
|
|
||||||
/* ---- 看板卡片 ⋯ 菜单入口 ---- */
|
/* ---- 看板卡片 ⋯ 菜单入口 ---- */
|
||||||
.bmore-btn { margin-left: auto; color: #c2c7cf; font-size: 14px; line-height: 1; padding: 2px 4px; cursor: pointer; }
|
.bmore-btn { color: #c2c7cf; font-size: 14px; line-height: 1; padding: 2px 4px; cursor: pointer; }
|
||||||
|
.bcard-left .bmore-btn { padding: 0 4px; }
|
||||||
|
|
||||||
/* ---- ActionSheet 标题 (任务 ⋯ 菜单) ---- */
|
/* ---- ActionSheet 标题 (任务 ⋯ 菜单) ---- */
|
||||||
.as-extra { padding: 4px 0 8px; }
|
.as-extra { padding: 4px 0 8px; }
|
||||||
.as-title { font-size: 13px; font-weight: 600; color: var(--color-text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
.as-title { font-size: 13px; font-weight: 600; color: var(--color-text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
|
||||||
/* ---- 键盘避让弹层 (重点设计 §7.2) ----
|
/* ---- 拖动手势 (重点设计 §7.1) ----
|
||||||
底部弹层统一挂 .kb-safe: 底部留白 = 安全区 + 20px + 键盘插入量(--kb-inset,
|
看板卡片 / 列表行: 纵向滚动归浏览器(pan-y), 水平位移留给拖动换列。
|
||||||
由 App 级 useKeyboardInsetWatcher 写入)。键盘弹出时内容抬到键盘上方,
|
任务区整体禁用长按文字选择与系统菜单, 避免长按拖动变成选中文字 (输入框除外)。 */
|
||||||
弹层自身 max-height 用 dvh 随视口收缩并内部滚动。 */
|
.task-content {
|
||||||
.kb-safe {
|
-webkit-user-select: none; user-select: none;
|
||||||
border-top-left-radius: 12px;
|
-webkit-touch-callout: none;
|
||||||
border-top-right-radius: 12px;
|
|
||||||
max-height: min(86dvh, calc(100dvh - 120px));
|
|
||||||
overflow-y: auto;
|
|
||||||
padding: 16px 16px calc(env(safe-area-inset-bottom, 0px) + 20px + var(--kb-inset, 0px));
|
|
||||||
}
|
}
|
||||||
|
.task-content input, .task-content textarea { -webkit-user-select: text; user-select: text; }
|
||||||
/* ---- 拖动方案 (重点设计 §7.1) ----
|
.bcard, .task-row { touch-action: pan-y; }
|
||||||
卡片允许纵向滚动(PAN-Y), 水平位移留给拖动; 激活后原位抬起 */
|
.bcard.dragging {
|
||||||
.bcard { touch-action: pan-y; }
|
opacity: .35; outline: 2px dashed var(--color-primary); outline-offset: 1px;
|
||||||
.bcard.dragging { opacity: .5; transform: scale(.97); box-shadow: 0 10px 22px rgba(15,23,42,.22); z-index: 6; }
|
}
|
||||||
|
.task-row.dragging {
|
||||||
|
opacity: .35; outline: 2px dashed var(--color-primary); outline-offset: 1px;
|
||||||
|
}
|
||||||
|
.task-row.dragover { outline: 2px solid var(--color-primary); outline-offset: 1px; }
|
||||||
|
.task-row.dragover.after { border-bottom: 3px solid var(--color-primary); }
|
||||||
|
.task-row.dragover.before { border-top: 3px solid var(--color-primary); }
|
||||||
|
.drag-pill {
|
||||||
|
font-size: 11px; font-weight: 600; color: #fff; background: var(--color-primary);
|
||||||
|
border-radius: 999px; padding: 2px 9px; flex-shrink: 0; white-space: nowrap;
|
||||||
|
}
|
||||||
|
.drag-pill.cur { background: #c0c7d0; }
|
||||||
|
|||||||
Reference in New Issue
Block a user