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 等), 需单独评估后执行。
378 lines
14 KiB
React
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>
|
|
)
|
|
}
|