Docker Build and Push / build-image (push) Successful in 1m35s
现象: 任务点完成后被自动移入 Done 分组, 但详情页状态仍是「未开始」。 根因: /api/powersync/upload 的 **更新分支** 漏了 status 字段 —— Task 更新分支逐个处理了 title/description/kind/markDate/extras/sortOrder/ assigneeId/doneAt/archived*/columnId/parentId, 唯独没有 d.status (离线新建分支是处理了的, 所以只有"改已有任务"这条路丢状态)。 客户端 api/task.js 本地写入时 status 与 columnId 是一起算、一起写的 (改状态会按列绑定归位到 Done 列), connector 上行回读整行, 所以 payload 里 status 一定存在 —— 是服务端 prisma.task.update 时没把它放进 data。 结果: 列变更落地、状态被丢弃; 下行同步以服务端为权威, 又把本地 status 覆盖 回去, 于是"看板在 Done、详情未开始"。 数据实证 (生产 PG): 9 条顶层任务处于 Done 列 (boundStatus=done) 但 status 非 done (8 条 todo + 1 条 doing), 且全部 doneAt 有值; 反向 (status=done 却 不在 done 列) 0 条 —— 单向故障, 排除下行覆盖/并发冲突。这 9 条的 columnId 与 doneAt 在同一个 update 里都写成功了, 唯独 status 没有, 只可能是它压根没进 data。 修复 (稳妥方案, 复用唯一口径): - resolveStatus.js 新增 resolveTaskUpload(): 客户端上行的是**本地整行**, 字段 总是存在, 不能用"字段是否存在"判断变更, 必须先与服务端现值比较, 再交给 resolveTaskWrite 裁决。不这样做会踩 T-BD-16: 任务从 Plan 拖到未绑定状态的 Action 列时, 整行里 status 仍是 todo, 若当作"本次变更"提交, 场景 B 会把任务 从 Action 归位回 Plan (拖过去又被弹回来)。 - powersync.js Task 更新分支改为调用 resolveTaskUpload, 并把 task.column 与 columnId 一起指向目标列 (否则场景 B 会拿旧列判断, 同样误归位); doneAt 与最终状态保持一致 (进入 done 记时 / 离开 done 清空, 口径同 REST)。 - Project 更新分支补 icon (客户端本地写了 icon, 服务端同样丢弃), 校验口径与 REST 写接口一致: 必须 data:image/ 前缀, 长度 <= 300_000。 - 上传处理器新增按项目缓存的 loadColumns (ops 已按表序排列, BoardColumn 先于 Task 落地, 故 Task 阶段读到的列是本批次最终结果)。 用例 (10 项, 含关键回归与旧思路对照): 仅换列到未绑定列保持列与状态 / 换列到绑定列状态跟随 / 仅改状态归位 / 无变化返回 null / 脏数据自愈 / 子任务豁免 / 无匹配列不移动 / 对照证明旧思路会把 Action 里的任务弹回 Plan。 存量脏数据 (9 条) 需另行订正为 done, 不在本提交内。
115 lines
5.0 KiB
JavaScript
115 lines
5.0 KiB
JavaScript
// 任务模块: 看板列 与 任务状态 的联动裁决 (唯一口径)
|
|
// 对应需求: T-BD-04/05/06/07/08/13/14/15/16
|
|
//
|
|
// 两个正交维度:
|
|
// Task.columnId 空间 (哪一列)
|
|
// Task.status 属性 (todo/doing/done)
|
|
// 列上 boundStatus 决定二者是否联动:
|
|
// null 该列与状态完全独立
|
|
// todo等 该列绑定到该状态
|
|
//
|
|
// 三种场景:
|
|
// A 拖拽换列 -> 只看目标列: 目标列绑定则状态跟随, 不绑定则保留原状态
|
|
// B 改状态 -> 当前列已匹配则不改列; 否则按列序找第一个绑定该状态的列 (找不到就不动)
|
|
// C 子任务 -> 跳过全部联动
|
|
|
|
/** 列按 sortOrder 升序 (调用方保证已排序, 这里再兜底一次) */
|
|
export function sortColumns(columns) {
|
|
return [...columns].sort((a, b) => (a.sortOrder - b.sortOrder) || (a.id - b.id))
|
|
}
|
|
|
|
/**
|
|
* 场景 A: 任务从任意列拖到 targetColumn
|
|
* @returns {{ status: string }} 新状态
|
|
*/
|
|
export function statusOnColumnChange(currentStatus, targetColumn) {
|
|
if (!targetColumn) return { status: currentStatus }
|
|
if (targetColumn.boundStatus) return { status: targetColumn.boundStatus }
|
|
// 目标列不绑定 => 保留原状态 (T-BD-07)
|
|
return { status: currentStatus }
|
|
}
|
|
|
|
/**
|
|
* 场景 B: 任务状态改为 newStatus 后, 应该落在哪一列
|
|
* @returns {{ columnId: number, moved: boolean }}
|
|
*/
|
|
export function columnOnStatusChange(task, newStatus, columns) {
|
|
// 已匹配 (当前列就绑定该状态) => 原地不动
|
|
if (task.column && task.column.boundStatus === newStatus) {
|
|
return { columnId: task.columnId, moved: false }
|
|
}
|
|
// 按当前列顺序取第一个绑定该状态的列 (T-BD-06, T-BD-15)
|
|
const ordered = sortColumns(columns || [])
|
|
const target = ordered.find((c) => c.boundStatus === newStatus)
|
|
if (target) return { columnId: target.id, moved: target.id !== task.columnId }
|
|
// 无匹配列 => 不移动, 保留原列 (T-BD-16)
|
|
return { columnId: task.columnId, moved: false }
|
|
}
|
|
|
|
/** 子任务豁免: 不参与任何列/状态联动 (T-BD-13) */
|
|
export function isSubtask(task) {
|
|
return task.parentId != null
|
|
}
|
|
|
|
/**
|
|
* 统一裁决入口: 写接口调用它算出最终的 status 与 columnId
|
|
*
|
|
* @param {object} task 现有任务 (含 columnId, status, parentId, column)
|
|
* @param {object} patch 本次提交 { columnId?, status? }
|
|
* @param {object[]} columns 项目全部列
|
|
* @returns {{ status: string, columnId: number }}
|
|
*/
|
|
export function resolveTaskWrite(task, patch, columns) {
|
|
let status = patch.status !== undefined ? patch.status : task.status
|
|
let columnId = patch.columnId !== undefined ? patch.columnId : task.columnId
|
|
|
|
// 子任务: 原样落库
|
|
if (isSubtask(task)) return { status, columnId }
|
|
|
|
const ordered = sortColumns(columns || [])
|
|
const targetColumn = ordered.find((c) => c.id === columnId)
|
|
|
|
// 场景 A: 显式换列
|
|
if (patch.columnId !== undefined && targetColumn) {
|
|
status = statusOnColumnChange(status, targetColumn).status
|
|
}
|
|
|
|
// 场景 B: 状态变了(或换列后状态变了) => 检查是否需要归位
|
|
// 注意: 只在"状态发生变化"或"显式改状态"时才归位, 单纯拖拽已由 A 处理
|
|
if (patch.status !== undefined) {
|
|
const next = columnOnStatusChange({ ...task, columnId, column: targetColumn }, status, ordered)
|
|
columnId = next.columnId
|
|
}
|
|
|
|
return { status, columnId }
|
|
}
|
|
|
|
/**
|
|
* 离线同步上传路径专用: 客户端上行的是**本地整行**, 字段总是存在, 因此不能用
|
|
* "字段是否存在"判断变更 —— 必须先与服务端现值比较, 得出"这次真正改了什么",
|
|
* 再交给 resolveTaskWrite 裁决。
|
|
*
|
|
* 不这样做会踩的坑 (T-BD-16): 任务从 Plan 拖到未绑定状态的 Action 列, 客户端
|
|
* 整行里 status 仍是 todo; 若把 status 当作"本次变更"提交, 场景 B 会把任务从
|
|
* Action 归位回第一个绑定 todo 的 Plan —— 用户拖到 Action 又被弹回去。
|
|
*
|
|
* @param {object} existing 服务端现有任务 (含 columnId/status/parentId)
|
|
* @param {object} incoming 本次上行值 { columnId?: number, status?: string }
|
|
* 外键需调用方先由 uuid 解析为内部 int id
|
|
* @param {object[]} columns 项目全部列
|
|
* @returns {{ status: string, columnId: number } | null} null = 列/状态无变化
|
|
*/
|
|
export function resolveTaskUpload(existing, incoming, columns) {
|
|
const nextColumnId = incoming?.columnId !== undefined ? incoming.columnId : existing.columnId
|
|
const patch = {}
|
|
if (nextColumnId !== existing.columnId) patch.columnId = nextColumnId
|
|
if (incoming?.status !== undefined && incoming.status !== existing.status) patch.status = incoming.status
|
|
if (!Object.keys(patch).length) return null
|
|
|
|
// task.column 必须与 columnId 一起指向目标列, 否则场景 B 的
|
|
// "当前列已匹配该状态" 会拿旧列判断, 同样导致误归位
|
|
const targetCol = (columns || []).find((c) => c.id === nextColumnId) || null
|
|
const r = resolveTaskWrite({ ...existing, columnId: nextColumnId, column: targetCol }, patch, columns)
|
|
return { status: r.status, columnId: r.columnId }
|
|
}
|