Files
iboard/src/pages/task/ProjectView.jsx
T
jif2.zhang fd4079c9c3
Docker Build and Push / build-image (push) Failing after 1m48s
feat(任务): 即时同步 —— 订阅本地库变更, 跨端改动无需切页自动刷新
现象: 多端同时打开同一项目时, 一端改完, 另一端必须切到别的页面再切回来才更新。

根因: 不是同步没到, 而是 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 等), 需单独评估后执行。
2026-10-27 20:57:05 +08:00

378 lines
14 KiB
React

import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { ActionSheet, Dialog, DotLoading, Toast } from 'antd-mobile'
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'
import { parseTimePrefix, splitTitle } from './quickAdd.js'
import ListView from './ListView.jsx'
import BoardView from './BoardView.jsx'
import TaskDetail from './TaskDetail.jsx'
import ProjectManage from './ProjectManage.jsx'
import CopyTaskPopup from '../../components/task/CopyTaskPopup.jsx'
import ArchivedTasks from './ArchivedTasks.jsx'
// 项目视图容器: 列表 / 看板 切换 + 数据装载 + 乐观更新
// 嵌在任务首屏下部 (T-VW-09); 头部仅保留「项目管理」与「+ 新增」(T-PJ-11)
export default function ProjectView({ projectId, onProjectChanged, onProjectExited }) {
const [project, setProject] = useState(null)
const [tasks, setTasks] = useState(null)
const [columns, setColumns] = useState([])
const [err, setErr] = useState('')
const [mode, setMode] = useState(() => getViewMode(projectId) || 'list')
const [openTask, setOpenTask] = useState(null)
const [showManage, setShowManage] = useState(false)
const [adding, setAdding] = useState(false)
const [tick, setTick] = useState(0) // 保留窗口重算触发
const [copyTask, setCopyTask] = useState(null) // ⋯菜单触发的跨项目拷贝
const [archivedOpen, setArchivedOpen] = useState(false) // 已归档任务查询
const load = useCallback(() => {
setErr('')
Promise.all([projectApi.detail(projectId), taskApi.list(projectId)])
.then(([p, t]) => {
setProject(p.item)
setColumns(p.item.columns || [])
setTasks(t.items || [])
})
.catch((e) => setErr(e.message))
}, [projectId])
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)
setViewMode(projectId, next)
}
// 保留窗口到点重算 (T-EX-18)
useEffect(() => {
const options = (columns || []).map((c) => c.retention)
const near = options
.map((o) => msToNextBoundary(o))
.filter((v) => v != null)
.sort((a, b) => a - b)[0]
const t = setTimeout(() => setTick((v) => v + 1), near ? Math.min(near, 60_000) : 60_000)
return () => clearTimeout(t)
}, [columns, tick])
const visibleTasks = useMemo(
() => filterVisible(tasks || [], columns),
// eslint-disable-next-line react-hooks/exhaustive-deps
[tasks, columns, tick],
)
// ---- 乐观更新 ----
const patchTask = (id, patch) => setTasks((prev) => (prev || []).map((t) => {
if (t.id === id) return { ...t, ...patch }
const kids = (t.children || []).map((c) => (c.id === id ? { ...c, ...patch } : c))
return { ...t, children: kids }
}))
const replaceTask = (fresh) => setTasks((prev) => (prev || []).map((t) => (t.id === fresh.id ? fresh : t)))
/** 详情页保存后: 先就地替换该行, 再回源刷新 (不回源也能立刻看到编号/列变更) */
const handleSaved = (fresh) => {
if (fresh?.id) replaceTask(fresh)
setOpenTask(null)
load()
}
/** 更新任务(列/状态/字段), 服务端裁决后以返回值为准 */
async function updateTask(id, patch) {
const snapshot = tasks
patchTask(id, patch)
try {
const r = await taskApi.update(id, patch)
replaceTask(r.item)
return r.item
} catch (e) {
setTasks(snapshot)
Toast.show({ content: e.message })
throw e
}
}
/** 勾选完成 / 取消完成 */
const toggleDone = (task) => updateTask(task.id, {
status: task.status === 'done' ? 'todo' : 'done',
})
/** 拖拽换列 (T-BD-04/07) */
const moveToColumn = async (task, columnId) => {
// columnId 来自拖动落点 (uuid 字符串); 空值直接忽略, 不要把坏 id 写进本地库
if (columnId === null || columnId === undefined || columnId === '') return
const col = columns.find((c) => sameId(c.id, columnId))
try {
await updateTask(task.id, { columnId })
const target = columns.find((c) => sameId(c.id, columnId))
if (target?.boundStatus && target.boundStatus !== task.status) {
Toast.show({ content: `已移入 ${col?.name},状态改为「${target.boundStatus === 'done' ? '完成' : target.boundStatus === 'doing' ? '执行中' : '未开始'}」` })
} else {
Toast.show({ content: `已移入 ${col?.name}` })
}
} 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) => sameId(t.columnId, c.id))).filter((l) => l.length)
const g = groups.find((l) => l.some((t) => sameId(t.id, task.id)))
if (!g) return
const rest = g.filter((t) => !sameId(t.id, task.id))
const ti = rest.findIndex((t) => sameId(t.id, targetId))
if (ti < 0) return
rest.splice(after ? ti + 1 : ti, 0, task)
const flat = groups.map((l) => (l === g ? rest : l)).flat()
const flatIds = new Set(flat.map((t) => t.id))
// 乐观更新必须重排数组本身 —— 列表分组渲染按数组顺序,
// 只改 sortOrder 字段界面不会动 (打卡侧即按此口径)
setTasks((prev) => [
...flat.map((t, i) => ({ ...t, sortOrder: i })),
...(prev || []).filter((t) => !flatIds.has(t.id)),
])
try {
await taskApi.reorder(projectId, flat.map((t) => t.id))
} catch (e) {
setTasks(snapshot)
Toast.show({ content: e.message || '排序保存失败' })
}
}
/** 改状态 (T-BD-14~16) */
const changeStatus = async (task, status) => {
try {
const fresh = await updateTask(task.id, { status })
const moved = fresh && !sameId(fresh.columnId, task.columnId)
if (moved) {
const col = columns.find((c) => sameId(c.id, fresh.columnId))
Toast.show({ content: `已移至 ${col?.name || '目标列'}` })
}
} catch { /* ignore */ }
}
/** 新建任务 (快速添加): 时间词前缀 → 时间标记; 超过 30 字在分隔符处截断,
* 前段为标题, 余文并入描述 (T-TK-14/16) */
async function createTask(rawTitle, parentId = null) {
const raw = String(rawTitle || '').trim()
if (!raw) return
const { markDate, rest } = parseTimePrefix(raw)
const { title, desc } = splitTitle(rest)
if (!title) return
try {
await taskApi.create(projectId, {
title,
description: desc || undefined,
markDate: markDate || undefined,
parentId,
})
setAdding(false)
load()
} catch (e) {
Toast.show({ content: e.message })
}
}
/** 任务 ⋯ 菜单: 编辑 / 拷贝到其他项目 / 删除 (点选后菜单收回) */
function openTaskMenu(task) {
const handler = ActionSheet.show({
actions: [
{ text: '编辑', key: 'edit' },
{ text: '归档', key: 'archive' },
{ text: '拷贝到其他项目', key: 'copy' },
{ text: '删除', key: 'delete', bold: true, danger: true },
],
cancelText: '取消',
onAction: async (item) => {
handler.close?.()
if (item.key === 'edit') setOpenTask(task)
else if (item.key === 'archive') {
const subs = task.children?.length || 0
const ok = await Dialog.confirm({
content: subs ? `归档「${task.title}」?将连带归档 ${subs} 条子任务。` : `归档「${task.title}」?`,
confirmText: '归档',
})
if (!ok) return
try {
await taskApi.archive(task.id)
Toast.show({ content: '已归档' })
load()
} catch (e) {
Toast.show({ content: e.message })
}
}
else if (item.key === 'copy') setCopyTask(task)
else if (item.key === 'delete') {
const kids = task.children?.length || 0
const ok = await Dialog.confirm({
content: kids
? `删除「${task.title}」?将一并删除 ${kids} 条子任务。`
: `删除「${task.title}」?`,
confirmText: '删除',
})
if (!ok) return
try {
await taskApi.remove(task.id)
Toast.show({ content: '已删除' })
load()
} catch (e) {
Toast.show({ content: e.message })
}
}
},
})
}
if (err) {
return (
<div className="pv-embed">
<div className="empty" style={{ paddingTop: 40 }}>
<div className="ico err">!</div>
<p>{err}</p>
<div className="mini-btn solid" onClick={load}>重试</div>
</div>
</div>
)
}
if (!project || !tasks) {
return (
<div className="pv-embed">
<div className="empty" style={{ padding: '40px 20px' }}>
<div className="ico">··</div><p>加载中…</p><DotLoading color="#1677ff" />
</div>
</div>
)
}
return (
<div className="pv-embed">
<div className="navbar-sub task-projbar">
<span className="title">{project.name}</span>
<div className="vseg">
<div className={mode === 'list' ? 'on' : ''} onClick={() => switchMode('list')}>列表</div>
<div className={mode === 'board' ? 'on' : ''} onClick={() => switchMode('board')}>看板</div>
</div>
<div className="right">
<span className="mini-btn" onClick={() => setArchivedOpen(true)}>已归档</span>
<span className="mini-btn" onClick={() => setShowManage(true)}>管理</span>
<span className="mini-btn solid" onClick={() => setAdding((v) => !v)}>+ 新增</span>
</div>
</div>
<div className={`task-content${mode === 'board' ? ' is-board' : ''}`}>
{adding && (
<div className="quick-add">
<textarea
className="inp"
autoFocus
rows={3}
maxLength={200}
placeholder={'输入任务标题,回车创建;开头可用 今天/明天/本周/本月 等时间词\n超过 30 字自动截断为标题,全文进描述'}
onBlur={(e) => { if (!e.currentTarget.value.trim()) setAdding(false) }}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); createTask(e.currentTarget.value) }
if (e.key === 'Escape') setAdding(false)
}}
/>
</div>
)}
{mode === 'list' ? (
<ListView
tasks={visibleTasks}
columns={columns}
onToggle={toggleDone}
onOpen={(t) => setOpenTask(t)}
onMore={openTaskMenu}
onMoveColumn={moveToColumn}
onReorder={reorderTask}
onDragActiveChange={onDragActiveChange}
/>
) : (
<BoardView
tasks={visibleTasks}
columns={columns}
onMoveColumn={moveToColumn}
onOpen={(t) => setOpenTask(t)}
onToggleSub={(c) => toggleDone(c)}
onToggle={(t) => toggleDone(t)}
onMore={openTaskMenu}
onDragActiveChange={onDragActiveChange}
onQuickAdd={() => { setAdding(true) }}
/>
)}
</div>
{openTask && (
<TaskDetail
task={openTask}
projectCode={project.code}
columns={columns}
members={project.members || []}
tasks={tasks || []}
taskKinds={project.taskKinds}
onClose={() => setOpenTask(null)}
onSaved={handleSaved}
onChanged={load}
/>
)}
{copyTask && (
<CopyTaskPopup
task={copyTask}
visible
onClose={() => setCopyTask(null)}
onDone={load}
/>
)}
{archivedOpen && (
<ArchivedTasks
project={project}
visible={archivedOpen}
onClose={() => setArchivedOpen(false)}
onChanged={load}
/>
)}
{showManage && (
<ProjectManage
project={project}
onClose={() => setShowManage(false)}
onChanged={() => { load(); onProjectChanged?.() }}
onExited={() => { setShowManage(false); onProjectExited?.() }}
/>
)}
</div>
)
}