// 任务模块: 看板列 与 任务状态 的联动裁决 (唯一口径) // 对应需求: 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 } }