feat(V0.2): 任务模块 —— 项目 / 列表与看板 / 列-状态绑定 / 一级子任务
Docker Build and Push / build-image (push) Failing after 3m51s
Docker Build and Push / build-image (push) Failing after 3m51s
需求 142 条 (T-*), 设计见 docs/V0.2/任务模块/ 数据层: - 新增 6 张表: Project / ProjectMember / BoardColumn / Task / TaskMember / TaskTag - 看板列是实体(可增删改排序), Task.columnId 外键 RESTRICT 防误删列丢任务 - Task.status(todo/doing/done) 与 columnId 双落库, 二者正交 领域层 server/task-domain/ (唯一口径, 前后端不重复实现): - resolveStatus: 列决定状态(只看目标列, 无拖出回退); 状态与列不匹配时按列序取第一个绑定该状态的列自动归位, 无匹配列则不移动; 子任务完全豁免 - retention: 逐列 6 档保留窗口(永不显示/当天/3天/本周/本月/永久显示), 以 doneAt 判定 - copyTask: 跨项目深拷贝, 人员按目标项目成员过滤并回报丢弃名单 接口: /api/projects, /api/columns, /api/tasks (均校验项目成员) 前端: - 底部 Tab 插入「任务」, 位于「打卡」之后; 默认首页仍为打卡 - 列表视图(子任务缩进+折叠) / 看板视图(长按拖拽换列、拖到卡片降为子任务) - 列设置(列管理+状态绑定+保留选项) / 任务详情(扩展信息卡片默认收起) / 成员管理 - 视图记忆与上次项目存设备端 已验证: 95 文件语法 0 错误、相对导入 0 缺失、领域逻辑用例全部通过
This commit is contained in:
@@ -0,0 +1,85 @@
|
||||
// 任务模块: 看板列 与 任务状态 的联动裁决 (唯一口径)
|
||||
// 对应需求: 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 }
|
||||
}
|
||||
Reference in New Issue
Block a user