feat(任务): 即时同步 —— 订阅本地库变更, 跨端改动无需切页自动刷新
Docker Build and Push / build-image (push) Failing after 1m48s

现象: 多端同时打开同一项目时, 一端改完, 另一端必须切到别的页面再切回来才更新。

根因: 不是同步没到, 而是 UI 从未订阅本地库变化 ——
PowerSync 的下行推送 (PG 逻辑复制 → sync service → 客户端本地 SQLite) 本身正常,
但任务模块的读全是命令式 db().getAll() (api/task.js), 读完不再看;
lib/powersync/react.jsx 虽然导出了 useQuery/useStatus, 全项目一次都没用过;
TaskPage/ProjectView 只在 useEffect 里 load() 一次, 连 useRefreshOnVisible 都没接
(只有打卡与宏观页面在用)。「切面板能刷新」纯粹是因为组件重新挂载又 load 了一次。

实现 (docs/V0.2/离线编辑模块/即时同步方案.md, 方案 C):
- 新增 lib/powersync/useDbSignal.js: 用 db.watch() 的表级过滤 (tables) + 节流
  (throttleMs) + AbortSignal, 订阅本地库变更; 跳过订阅时的首次通知, 避免与
  挂载时的 load() 重复。
  (初版设计是手写 count/sum/max(updatedAt) 的「信号 SQL」, 实施时发现 SDK 原生
   支持 tables 过滤, 更可靠, 已改为表过滤并在文档中记录变更。)
- api/task.js 新增 TASK_TABLES / PROJECT_TABLES 常量 (表名须与客户端 schema 一致)。
- ProjectView: 订阅任务域 (Task/BoardColumn/TaskMember/TaskTag) → 重算;
  TaskPage: 订阅项目域 (Project/ProjectMember) → 重算。
- 不打断操作: BoardView/ListView 上报 onDragActiveChange, 拖拽期间收到变更只记
  pending, 松手后补一次刷新, 避免卡片在手指下跳位。
- 回前台兜底: 两页都接 useRefreshOnVisible (WebView 后台可能节流订阅回调)。

验证 (真实 Windows 外壳 + CDP, 服务端直接改库模拟另一端):
- 服务端改任务标题后, 页面在**无任何操作**的情况下自动更新 (三次复测均成功);
- 改回原标题后同样自动还原; 测试数据已恢复。

已知瓶颈 (不在 UI 层, 另见文档 §7.0):
- 实测端到端延迟约 13~14s, 未达 ≤1s 目标;
- CDP Network 观察客户端: 无 WebSocket, 仅 /sync/stream 长连接;
- NPM (baishe nginx-app-1, proxy_host/5.conf) 未关闭 proxy_buffering,
  流式响应被 nginx 缓冲后按批下发, 与观测到的固定批次延迟吻合;
- 修复属运维配置 (proxy_buffering off 等), 需单独评估后执行。
This commit is contained in:
jif2.zhang
2026-10-27 20:57:05 +08:00
parent 86335dfaa4
commit fd4079c9c3
7 changed files with 418 additions and 6 deletions
@@ -0,0 +1,297 @@
# 任务模块「即时同步」方案
> 状态:**方案设计,未实施**(本文只描述做法,不含代码改动)
> 范围:任务模块(Project / BoardColumn / Task / TaskMember / TaskTag)
> 相关文档:[0.2.1 实施方案](./0.2.1实施方案.md)、[PowerSync 可行性研究报告](./PowerSync可行性研究报告.md)
> 编写时间:2026-10-27
---
## 1. 问题与现状
### 1.1 现象
多端(Windows 外壳 / Android / Web)同时打开同一项目时:
- A 端点完成或改状态,**B 端不会自动更新**;
- 必须**切到别的页面再切回来**(组件重新挂载触发重新查询)才能看到;
- 看板分组(按 `columnId`)与详情状态(按 `status`)也会出现一段时间的显示不一致。
### 1.2 根因:不是"同步没到",而是"到了没人通知界面"
PowerSync 的下行链路本身是**推送式**的(服务端 PG 逻辑复制 → sync service → WebSocket →
客户端写入本地 SQLite),延迟通常在亚秒级。问题出在**消费端**:
| 层 | 现状 | 结论 |
| --- | --- | --- |
| 传输层 | `PowerSyncProvider` 已在 `src/App.jsx` 挂载,连接正常 | ✅ 正常 |
| 数据层 | 变化已写入本地 SQLite | ✅ 正常 |
| **UI 层** | **全部是命令式读取**:`db().getAll(...)`,读完就不再看 | ❌ **断点在这** |
具体地:
- `src/api/task.js` 的 `projectApi.list/detail`、`taskApi.list` 都是一次性 `getAll()`,
且在 JS 里做二次装配(子任务、成员、标签、列),**没有任何订阅**;
- `src/lib/powersync/react.jsx` 虽然 re-export 了 `useQuery/useStatus`,
但**全项目一次都没用过**(`grep useQuery src` 只命中该文件的导出语句);
- `src/pages/task/TaskPage.jsx`、`ProjectView.jsx` 只在 `useEffect(..., [load])` 里加载一次;
- 唯一的刷新机制是 `src/hooks/useRefreshOnVisible.js`(回前台/窗口聚焦时重查),
而且**任务模块这两个页面根本没有用它**(只有打卡 `MergedView/ListView` 与宏观各页在用)。
所以「切面板能刷新」只是因为它**重新挂载组件 → 重新执行一次 `load()`**。
### 1.3 现状刷新机制盘点
| 页面 | 首次加载 | 回前台刷新 | 数据变化实时反映 |
| --- | --- | --- | --- |
| `TaskPage` | `useEffect` | ❌ 无 | ❌ |
| `ProjectView` | `useEffect` | ❌ 无 | ❌(仅本地操作后手动 `load()`) |
| `TaskDetail` | 打开时传入快照 | — | ❌(快照可能过期) |
| `MergedView`(打卡) | `useEffect` | ✅ | ❌(且打卡走 REST,非 PowerSync) |
---
## 2. 目标与非目标
### 2.1 目标
1. **跨端即时**:一端修改,其它端在 **≤1s** 内自动呈现(不含网络中断场景);
2. **不打断操作**:正在输入的详情表单、正在拖拽的卡片**不被远端变更打断**;
3. **离线可用**:断网期间照常读写本地库,恢复后自动补齐并刷新;
4. **改动可控**:优先复用现有装配逻辑(`taskApi` 的 shape),不做大规模重写;
5. **可回滚**:通过开关或逐步替换,出问题能退回"手动刷新"。
### 2.2 非目标
- 不做**多人协同编辑**(同一字段并发写仍按 LWW 后到覆盖);
- 不解决打卡/清单模块的实时性(它们走 REST,见 §7);
- 不引入 WebSocket 之外的额外实时通道(如 SSE)。
---
## 3. 方案选型
| 方案 | 做法 | 优点 | 缺点 | 结论 |
| --- | --- | --- | --- | --- |
| **A. 轮询** | 定时(如 3s)重跑 `load()` | 改动最小 | 无谓查询、电量/流量、仍不"即时" | ❌ |
| **B. 全量响应式** | 把 `taskApi.list` 整体改成一条大 SQL + `useQuery` | 最"正统",细粒度 | 现有装配(子任务/成员/标签)要在 SQL 里重写;改动面大、回归风险高 | ⏳ 后续 |
| **C. 变更信号 + 重算**(推荐) | 用 `db.watch()` 订阅一个**轻量"信号查询"**,信号变化时重跑现有 `load()` | 复用现有装配逻辑;改动集中且小;天然合并多次变更 | 每次重算一次列表(数据量小,可接受) | ✅ **P0 采用** |
| **D. 表级广播** | 监听"某表有变更"事件触发重查 | 实现最简单 | 无法区分项目/范围,易全页面重查 | 作为 C 的退化实现 |
**结论**:P0 用方案 C;跑稳后再评估把高频视图(看板)迁到 B 的增量查询。
---
## 4. 推荐方案详设(方案 C)
### 4.1 核心思路
```
本地 SQLite ←(PowerSync 下行写入)— 任一端/服务端变更
│
└─▶ db.watch(<信号查询>) ── 变化 ──▶ 节流合并 ──▶ 重跑 load()
└─▶ setState 渲染
```
**"信号查询"** 是一段**廉价但能覆盖所有相关表**的 SQL:只要任务域的任一相关行变化,
其结果就会变;它不承担业务语义,只负责触发。
### 4.2 订阅范围:用 SDK 的表级过滤(已实施)
> 初版设计是手写一段"信号 SQL"(count / sum / max(updatedAt) 汇总),
> 实施时发现 `db.watch()` 原生支持 **`tables` 表名过滤** 与 **`throttleMs` 节流**,
> 由 SDK 判断"结果是否真的变了",比自己做汇总更可靠,因此改为表级过滤。
`CommonPowerSyncDatabase.watch(sql, params, handler, options)` 的 `options`:
| 选项 | 用途 |
| --- | --- |
| `tables: string[]` | 只关心这些表的变更,SDK 内部按表失效 |
| `throttleMs` | 两次回调之间的最小间隔,天然合并同一批同步写入 |
| `signal: AbortSignal` | 卸载时取消订阅 |
订阅内容(`src/lib/powersync/useDbSignal.js`):
```js
db.watch('SELECT 1 AS s', [], { onResult, onError }, {
tables: ['Task', 'BoardColumn', 'TaskMember', 'TaskTag'],
throttleMs: 250,
signal: ac.signal,
})
```
查询本身(`SELECT 1`)没有业务含义,只借 watch 的变更通知能力。表名集合定义在
`src/api/task.js`:
| 常量 | 内容 | 用于 |
| --- | --- | --- |
| `TASK_TABLES` | `Task, BoardColumn, TaskMember, TaskTag` | 项目内任务列表 / 看板 |
| `PROJECT_TABLES` | `Project, ProjectMember` | 项目列表、成员 |
之所以不需要 `max(updatedAt)`:表级过滤关心的是"行有没有被写过",
而不是"结果集内容是否变化",所以改标题、改状态这类不增删的写入同样会触发。
### 4.3 UI 层接入
提供两个小工具,避免每个页面各写一套:
| 工具 | 位置(建议) | 作用 |
| --- | --- | --- |
| `useDbSignal(sql, params, onChange)` | `src/lib/powersync/useDbSignal.js`(新增) | 封装 `db.watch()` + 节流 + 卸载清理 |
| `useTaskListSignal(projectId, onChange)` | `src/api/task.js` 或同目录(新增) | 拼好上述信号 SQL,页面只关心"变了要干嘛" |
接入示意(`ProjectView`):
```jsx
// 现状
useEffect(() => { setTasks(null); load() }, [load])
// 改造后(示意)
useEffect(() => { setTasks(null); load() }, [load])
useDbSignal(TASK_LIST_SIGNAL_SQL, [projectId, projectId, projectId, projectId, projectId, projectId], () => {
if (dragActiveRef.current) return // 见 §4.4
load()
})
```
`TaskPage` 同理,信号为项目表。
### 4.4 变更合并与"不打断"策略
这是本方案的关键,否则会出现「打字打到一半表单被重置」「拖到一半卡片跳走」。
| 场景 | 策略 |
| --- | --- |
| 连续多次变更(一次同步写多行) | `useDbSignal` 内部 **150~300ms 防抖**,合并成一次 `load()` |
| 看板/列表**拖拽进行中** | 用 `dragActiveRef` 跳过本次刷新;拖拽结束后补一次 `load()` |
| 任务**详情页打开中** | 不自动刷新表单;改为监听该任务是否变化,变化时在详情顶部显示「**该任务已在他端更新,点击刷新**」 |
| 正在编辑标题/描述 | 同上传:不覆盖输入框,只提示 |
| 本地乐观更新进行中(`updateTask` 未完成) | 以本地 patch 为准,`load()` 延后到请求结束(现有 `updateTask` 已有 snapshot 回滚,保留) |
### 4.5 同步状态指示
用已导出的 `useStatus()` 在任务页顶部显示一个极轻量的状态点:
| 状态 | 展示 |
| --- | --- |
| `connected && dataFlowStatus.connected` | 不显示(无干扰) |
| 正在下载/上传 | 细小的同步图标 |
| 离线 / 连接失败 | 「离线,改动将在联网后同步」 |
> 说明:当前外壳里 PowerSync 会打 `Multiple tab support is not enabled` 的提示,
> 属预期(已在 0.2.1 关闭多标签以避免 Windows reload 后本地库挂起),不影响本方案。
### 4.6 离线与重连
- 断网:`db.watch()` 仍监听**本地库**,本地写入照常触发刷新(体验一致);
- 恢复:PowerSync 自动重连并回放,写入本地库后信号触发刷新,**无需额外逻辑**;
- 应用回到前台:保留 `useRefreshOnVisible` 作为**兜底**(WebView 可能挂起渲染线程),
与 `db.watch()` 并存不冲突(两者都只是调 `load()`)。
---
## 5. 改造清单(按文件)
| 文件 | 改动 | 状态 |
| --- | --- | --- |
| `src/lib/powersync/useDbSignal.js` | **新增**:`db.watch` + 表过滤 + 节流 + 卸载清理 | ✅ P0 已完成 |
| `src/api/task.js` | 新增 `TASK_TABLES` / `PROJECT_TABLES` 常量 | ✅ P0 |
| `src/pages/task/ProjectView.jsx` | 订阅任务域;拖拽中延后刷新;回前台兜底 | ✅ P0 |
| `src/pages/task/TaskPage.jsx` | 订阅项目域;回前台兜底 | ✅ P0 |
| `src/pages/task/BoardView.jsx` / `ListView.jsx` | 上报 `onDragActiveChange` | ✅ P0 |
| `src/pages/task/TaskDetail.jsx` | 「已在他端更新」提示(不覆盖表单) | ⏳ P1 |
| 同步状态指示 | 用 `useStatus()` 显示离线/同步中 | ⏳ P1 |
**不需要改动**:`server/`、`sync-config.yaml`、数据库结构。
---
## 6. 分期与验收
### 6.1 分期
| 阶段 | 内容 | 预估 |
| --- | --- | --- |
| **P0** | `useDbSignal` + `ProjectView`/`TaskPage` 接入 + 拖拽保护 | 0.5~1 天 |
| **P1** | 详情页「已在他端更新」提示 + 同步状态指示 | 0.5 天 |
| **P2** | 看板改增量查询(方案 B);打卡/清单的实时性另议 | 按需 |
### 6.2 验收标准(可测)
1. **跨端延迟**:A 端改状态/拖列/改标题,B 端 **≤1s** 自动更新(同一局域网/公网各测一次);
2. **不打断**:B 端正在输入标题时,A 端改同任务描述 → B 端输入框内容**不被清空**,仅出现提示;
3. **拖拽安全**:B 端拖拽中收到远端变更 → 卡片**不跳位**,松手后列表更新;
4. **离线**:断网期间本地操作正常,恢复后自动补齐并刷新;
5. **回滚**:置开关关闭后,行为退回当前"手动刷新",无副作用;
6. **性能**:单项目 100 条任务、连续 20 次远端写入,页面无卡顿、无重复渲染风暴(可用 Performance 面板核对)。
### 6.3 度量
- 在 `load()` 内打点(可临时 `console.time`),统计重算耗时与触发次数;
- 目标:一次远端变更只触发 **1 次** 重算;100 条任务时单次重算 < 60ms。
---
## 7. 已知边界与后续
### 7.0 实测:UI 已自动刷新,但传输层延迟约 14s(待修)
P0 落地后实测(真实 Windows 外壳 + 服务端直接改库):
| 项 | 结果 |
| --- | --- |
| 是否还需要切页面 | **不需要** —— 服务端改完标题,页面在无任何操作的情况下自动更新 ✅ |
| 端到端延迟 | **约 13~14s**(三次复测一致),远超 ≤1s 目标 ❌ |
排查结论:**延迟不在 UI 层**。用 CDP Network 观察客户端,发现:
- **没有任何 WebSocket 连接**;
- 只有两次 `https://iboard.honor3.com/sync/stream` 请求。
而 NPM(baishe 上的 `nginx-app-1`,配置 `/data/nginx/proxy_host/5.conf`)对
`iboard.honor3.com` 只设置了 `Upgrade` / `Connection` / `proxy_http_version 1.1`,
**没有关闭 `proxy_buffering`**(nginx 默认开启)。PowerSync 的 `/sync/stream`
是长连接流式(NDJSON)响应,被 nginx 缓冲后按批下发 —— 与观测到的固定 ~14s 批次延迟吻合。
**待办(运维,非代码)**:为 `iboard.honor3.com` 的 `/sync` 增加
```nginx
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 1h; # 避免长连接被掐断
chunked_transfer_encoding on;
```
改完需重载 nginx,并复测延迟。注意该 NPM 实例同时承载 gitea / homepage 等站点,
改动应只作用于该 proxy_host,避免影响其它服务。
| 项 | 说明 |
| --- | --- |
| 打卡 / 清单 | 走 REST(`src/api/checkin.js`),不经过本地库,**本项目方案不覆盖**;如需实时,可复用现有 `useRefreshOnVisible` + 定时轮询,或后续把这些模块也迁到 PowerSync |
| 并发写冲突 | 仍为 LWW(`updatedAt` 后到覆盖);本文不引入人工合并 |
| 排序并发 | `taskApi.reorder` 会整批重写 `sortOrder`,两端同时拖拽可能相互覆盖;建议 P2 再引入"版本号/操作序列" |
| 大列表 | 方案 C 每次全量重算,任务数极大(>1000)时需转方案 B 的增量查询 |
| WebView 后台节流 | 应用在后台时 `watch` 回调可能被延后,回前台由 `useRefreshOnVisible` 兜底 |
---
## 8. 附:关键 API 备忘
| API | 来源 | 用途 |
| --- | --- | --- |
| `db.watch(sql, params, options)` | `@powersync/common` `CommonPowerSyncDatabase` | 订阅查询结果,返回 `AsyncIterable` |
| `useQuery(sql, params, options)` | `@powersync/react` | React 版订阅;`options.rowComparator` 可做**增量**更新(P2 用) |
| `useStatus()` | `@powersync/react` | 连接/同步状态 |
| `getPowersyncDb()` | `src/lib/powersync/db.js` | 本地库单例(当前唯一入口) |
> 注:`src/lib/powersync/react.jsx` 已 re-export `useQuery/useStatus/usePowerSync`,
> 本次改造**无需新增依赖**。
---
## 9. 结论
当前"必须切面板才刷新"**不是同步没到,而是 UI 从未订阅本地库变化**。
采用「**变更信号 + 复用现有装配重算**」(方案 C)可在改动面很小的情况下获得亚秒级跨端同步,
并把"不打断编辑/拖拽"作为硬性要求一并纳入。P0 约半天到一天即可上线验证。
+7
View File
@@ -35,6 +35,13 @@ const DEFAULT_COLUMNS = [
export const DEFAULT_TASK_KINDS = ['需求', 'Bug', 'FEATURE', '优化'] export const DEFAULT_TASK_KINDS = ['需求', 'Bug', 'FEATURE', '优化']
const MAX_NAME = 20 const MAX_NAME = 20
// 即时同步: 订阅用的本地表集合 (见 docs/V0.2/离线编辑模块/即时同步方案.md)
// TASK_TABLES 影响"某项目的任务列表/看板"的内容
// PROJECT_TABLES 影响"项目列表"的内容
// 只用于变更通知, 不参与查询; 表名必须与 PowerSync 客户端 schema 一致。
export const TASK_TABLES = ['Task', 'BoardColumn', 'TaskMember', 'TaskTag']
export const PROJECT_TABLES = ['Project', 'ProjectMember']
function normTaskKinds(v) { function normTaskKinds(v) {
if (!Array.isArray(v)) return null if (!Array.isArray(v)) return null
const out = [] const out = []
+66
View File
@@ -0,0 +1,66 @@
// 本地库变更订阅 (任务模块即时同步, 见 docs/V0.2/离线编辑模块/即时同步方案.md)
//
// 作用: 当指定表在本地 SQLite 里发生变化时回调 —— 变化可能来自
// 1) 本端写入 (乐观更新之后)
// 2) PowerSync 下行推送 (其它端 / 服务端改动)
// 两者最终都落到本地库, 所以订阅本地库即可覆盖跨端场景, 且离线时同样有效。
//
// 为什么不用手写"信号 SQL"求和: SDK 的 watch 已支持按表名过滤 (tables)
// + 节流 (throttleMs), 由 SDK 判断是否真的变化, 比自己做 count/max 汇总可靠。
//
// 用法:
// useDbSignal(['Task', 'BoardColumn'], () => load())
// useDbSignal(TASK_TABLES, onChange, { throttleMs: 300, enabled: someFlag })
import { useEffect, useRef } from 'react'
import { getPowersyncDb } from './db.js'
// 无意义的占位查询: 只借 watch 的"变更通知"能力, 不关心结果
const SIGNAL_SQL = 'SELECT 1 AS s'
export default function useDbSignal(tables, onChange, options = {}) {
const { throttleMs = 250, enabled = true } = options
// 回调走 ref, 避免调用方每次渲染都重新订阅
const cbRef = useRef(onChange)
cbRef.current = onChange
// 表名数组每帧都是新引用, 用内容做依赖
const tablesKey = Array.isArray(tables) ? tables.join(',') : ''
useEffect(() => {
if (!enabled || !tablesKey) return undefined
const db = getPowersyncDb()
const ac = new AbortController()
let disposed = false
// watch 订阅成功后会立刻给出一次当前结果, 而调用方挂载时已经主动 load 过,
// 这里跳过首次通知, 避免重复请求
let first = true
try {
db.watch(
SIGNAL_SQL,
[],
{
onResult: () => {
if (disposed) return
if (first) { first = false; return }
cbRef.current?.()
},
onError: (e) => {
if (!disposed) console.warn('[db-signal] watch 出错:', e)
},
},
{ tables: tablesKey.split(','), throttleMs, signal: ac.signal },
)
} catch (e) {
// 订阅失败不应影响页面主流程 (退化为"手动刷新")
console.warn('[db-signal] 订阅失败:', e)
}
return () => {
disposed = true
ac.abort()
}
}, [tablesKey, throttleMs, enabled])
}
+6 -1
View File
@@ -36,10 +36,15 @@ function ScrollBody({ children }) {
// 横向滚列; 列内独立纵向滚动, 列头固定, 滚动时右缘显示滚动条 // 横向滚列; 列内独立纵向滚动, 列头固定, 滚动时右缘显示滚动条
// 不做"拖到卡片上转子任务" —— 统一走详情页「转为子任务」 // 不做"拖到卡片上转子任务" —— 统一走详情页「转为子任务」
export default function BoardView({ export default function BoardView({
tasks, columns, onMoveColumn, onOpen, onToggleSub, onToggle, onMore, onQuickAdd, tasks, columns, onMoveColumn, onOpen, onToggleSub, onToggle, onMore, onQuickAdd, onDragActiveChange,
}) { }) {
const drag = useColumnDrag({ onDrop: onMoveColumn }) const drag = useColumnDrag({ onDrop: onMoveColumn })
// 把"是否正在拖拽"上报给容器: 拖拽期间收到远端变更时, 容器会延后刷新,
// 避免卡片在手中跳位 (见 docs/V0.2/离线编辑模块/即时同步方案.md §4.4)
const dragging = !!drag.dragTask
useEffect(() => { onDragActiveChange?.(dragging) }, [dragging, onDragActiveChange])
const ordered = [...(columns || [])].sort((a, b) => a.sortOrder - b.sortOrder) const ordered = [...(columns || [])].sort((a, b) => a.sortOrder - b.sortOrder)
return ( return (
+6 -2
View File
@@ -1,4 +1,4 @@
import { useState } from 'react' import { useEffect, useState } from 'react'
import TaskRow from '../../components/task/TaskRow.jsx' import TaskRow from '../../components/task/TaskRow.jsx'
import useRowDrag from '../../components/task/useRowDrag.js' import useRowDrag from '../../components/task/useRowDrag.js'
@@ -7,11 +7,15 @@ import useRowDrag from '../../components/task/useRowDrag.js'
// 换列: 行/把手拖动, 跟手浮层, 手指划到哪个分组松手就落到哪 (与看板一致) // 换列: 行/把手拖动, 跟手浮层, 手指划到哪个分组松手就落到哪 (与看板一致)
// (列表只展示有任务的列, 空列需到看板视图拖入) // (列表只展示有任务的列, 空列需到看板视图拖入)
export default function ListView({ export default function ListView({
tasks, columns, onToggle, onOpen, onMore, onMoveColumn, onReorder, tasks, columns, onToggle, onOpen, onMore, onMoveColumn, onReorder, onDragActiveChange,
}) { }) {
const [collapsed, setCollapsed] = useState({}) const [collapsed, setCollapsed] = useState({})
const drag = useRowDrag({ columns, onDrop: onMoveColumn, onReorder }) const drag = useRowDrag({ columns, onDrop: onMoveColumn, onReorder })
// 拖拽期间延后远端变更触发的刷新, 避免行在手中跳位
const dragging = !!drag.drag
useEffect(() => { onDragActiveChange?.(dragging) }, [dragging, onDragActiveChange])
if (!tasks || tasks.length === 0) { if (!tasks || tasks.length === 0) {
return ( return (
<div className="empty" style={{ paddingTop: 90 }}> <div className="empty" style={{ paddingTop: 90 }}>
+28 -2
View File
@@ -1,6 +1,8 @@
import { useCallback, useEffect, useMemo, useState } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { ActionSheet, Dialog, DotLoading, Toast } from 'antd-mobile' import { ActionSheet, Dialog, DotLoading, Toast } from 'antd-mobile'
import { projectApi, taskApi } from '../../api/task.js' import { projectApi, taskApi, TASK_TABLES } from '../../api/task.js'
import useDbSignal from '../../lib/powersync/useDbSignal.js'
import useRefreshOnVisible from '../../hooks/useRefreshOnVisible.js'
import { filterVisible, msToNextBoundary } from './date.js' import { filterVisible, msToNextBoundary } from './date.js'
import { sameId } from './ids.js' import { sameId } from './ids.js'
import { getViewMode, setViewMode } from './prefs.js' import { getViewMode, setViewMode } from './prefs.js'
@@ -40,6 +42,28 @@ export default function ProjectView({ projectId, onProjectChanged, onProjectExit
useEffect(() => { setTasks(null); load() }, [load]) useEffect(() => { setTasks(null); load() }, [load])
// ---- 即时同步 (docs/V0.2/离线编辑模块/即时同步方案.md) ----
// 任务域任一相关表在本地库变化 -> 重算。变化来源既包括本端写入, 也包括
// PowerSync 从其它端推下来的改动, 所以跨端状态无需切页面即可刷新。
const draggingRef = useRef(false)
const pendingRef = useRef(false) // 拖拽期间收到的变更, 松手后补一次
const onDragActiveChange = useCallback((active) => {
draggingRef.current = active
if (!active && pendingRef.current) {
pendingRef.current = false
load()
}
}, [load])
useDbSignal(TASK_TABLES, () => {
// 拖拽中不刷新: 否则卡片会在手指下跳位
if (draggingRef.current) { pendingRef.current = true; return }
load()
})
// 兜底: 应用回到前台/窗口聚焦时补一次 (WebView 在后台可能节流订阅回调)
useRefreshOnVisible(() => { if (!draggingRef.current) load() })
// 切换视图并记忆 (T-VW-02/03/06) // 切换视图并记忆 (T-VW-02/03/06)
const switchMode = (next) => { const switchMode = (next) => {
setMode(next) setMode(next)
@@ -291,6 +315,7 @@ export default function ProjectView({ projectId, onProjectChanged, onProjectExit
onMore={openTaskMenu} onMore={openTaskMenu}
onMoveColumn={moveToColumn} onMoveColumn={moveToColumn}
onReorder={reorderTask} onReorder={reorderTask}
onDragActiveChange={onDragActiveChange}
/> />
) : ( ) : (
<BoardView <BoardView
@@ -301,6 +326,7 @@ export default function ProjectView({ projectId, onProjectChanged, onProjectExit
onToggleSub={(c) => toggleDone(c)} onToggleSub={(c) => toggleDone(c)}
onToggle={(t) => toggleDone(t)} onToggle={(t) => toggleDone(t)}
onMore={openTaskMenu} onMore={openTaskMenu}
onDragActiveChange={onDragActiveChange}
onQuickAdd={() => { setAdding(true) }} onQuickAdd={() => { setAdding(true) }}
/> />
)} )}
+8 -1
View File
@@ -2,7 +2,9 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useLocation, useNavigate, useParams } from 'react-router-dom' import { useLocation, useNavigate, useParams } from 'react-router-dom'
import { Dialog, DotLoading, Input, Toast } from 'antd-mobile' import { Dialog, DotLoading, Input, Toast } from 'antd-mobile'
import PageHeader from '../../components/PageHeader.jsx' import PageHeader from '../../components/PageHeader.jsx'
import { projectApi } from '../../api/task.js' import { projectApi, PROJECT_TABLES } from '../../api/task.js'
import useDbSignal from '../../lib/powersync/useDbSignal.js'
import useRefreshOnVisible from '../../hooks/useRefreshOnVisible.js'
import { import {
getLastProjectId, setLastProjectId, setRecentIds, prunePrefs, getLastProjectId, setLastProjectId, setRecentIds, prunePrefs,
getRecentIds, getRecentOrder, setRecentOrder, getRecentIds, getRecentOrder, setRecentOrder,
@@ -37,6 +39,11 @@ export default function TaskPage() {
useEffect(() => { load() }, [load]) useEffect(() => { load() }, [load])
// 即时同步: 项目/成员变化 (含其它端改名、增删、归档) 时刷新项目列表
useDbSignal(PROJECT_TABLES, load)
// 兜底: 回前台补一次 (WebView 后台可能节流订阅回调)
useRefreshOnVisible(load)
// 决定当前项目: 深链 pid > 上次访问 > 个人默认项目 (T-VW-09) // 决定当前项目: 深链 pid > 上次访问 > 个人默认项目 (T-VW-09)
useEffect(() => { useEffect(() => {
if (!projects) return if (!projects) return