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
+7
View File
@@ -35,6 +35,13 @@ const DEFAULT_COLUMNS = [
export const DEFAULT_TASK_KINDS = ['需求', 'Bug', 'FEATURE', '优化']
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) {
if (!Array.isArray(v)) return null
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({
tasks, columns, onMoveColumn, onOpen, onToggleSub, onToggle, onMore, onQuickAdd,
tasks, columns, onMoveColumn, onOpen, onToggleSub, onToggle, onMore, onQuickAdd, onDragActiveChange,
}) {
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)
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 useRowDrag from '../../components/task/useRowDrag.js'
@@ -7,11 +7,15 @@ import useRowDrag from '../../components/task/useRowDrag.js'
// 换列: 行/把手拖动, 跟手浮层, 手指划到哪个分组松手就落到哪 (与看板一致)
// (列表只展示有任务的列, 空列需到看板视图拖入)
export default function ListView({
tasks, columns, onToggle, onOpen, onMore, onMoveColumn, onReorder,
tasks, columns, onToggle, onOpen, onMore, onMoveColumn, onReorder, onDragActiveChange,
}) {
const [collapsed, setCollapsed] = useState({})
const drag = useRowDrag({ columns, onDrop: onMoveColumn, onReorder })
// 拖拽期间延后远端变更触发的刷新, 避免行在手中跳位
const dragging = !!drag.drag
useEffect(() => { onDragActiveChange?.(dragging) }, [dragging, onDragActiveChange])
if (!tasks || tasks.length === 0) {
return (
<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 { 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 { sameId } from './ids.js'
import { getViewMode, setViewMode } from './prefs.js'
@@ -40,6 +42,28 @@ export default function ProjectView({ projectId, onProjectChanged, onProjectExit
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)
const switchMode = (next) => {
setMode(next)
@@ -291,6 +315,7 @@ export default function ProjectView({ projectId, onProjectChanged, onProjectExit
onMore={openTaskMenu}
onMoveColumn={moveToColumn}
onReorder={reorderTask}
onDragActiveChange={onDragActiveChange}
/>
) : (
<BoardView
@@ -301,6 +326,7 @@ export default function ProjectView({ projectId, onProjectChanged, onProjectExit
onToggleSub={(c) => toggleDone(c)}
onToggle={(t) => toggleDone(t)}
onMore={openTaskMenu}
onDragActiveChange={onDragActiveChange}
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 { Dialog, DotLoading, Input, Toast } from 'antd-mobile'
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 {
getLastProjectId, setLastProjectId, setRecentIds, prunePrefs,
getRecentIds, getRecentOrder, setRecentOrder,
@@ -37,6 +39,11 @@ export default function TaskPage() {
useEffect(() => { load() }, [load])
// 即时同步: 项目/成员变化 (含其它端改名、增删、归档) 时刷新项目列表
useDbSignal(PROJECT_TABLES, load)
// 兜底: 回前台补一次 (WebView 后台可能节流订阅回调)
useRefreshOnVisible(load)
// 决定当前项目: 深链 pid > 上次访问 > 个人默认项目 (T-VW-09)
useEffect(() => {
if (!projects) return