feat(V0.2): 任务模块 —— 项目 / 列表与看板 / 列-状态绑定 / 一级子任务
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:
jif2.zhang
2026-10-24 11:21:16 +08:00
parent 42acf72f1e
commit 6c9cd3fd99
36 changed files with 6194 additions and 0 deletions
+58
View File
@@ -0,0 +1,58 @@
// 任务模块: 项目成员校验 (T-PM-01, B-13)
// 所有 /api/projects/:id/* 与任务写操作都必须确认请求者是项目成员.
import { prisma } from '../db.js'
/** 取出当前用户可访问的项目 (不存在或非成员 -> null) */
export async function loadProjectForUser(projectId, userId) {
const id = Number(projectId)
if (!Number.isInteger(id) || id <= 0) return null
const project = await prisma.project.findFirst({
where: { id, members: { some: { userId } } },
})
return project || null
}
/** 任务 -> 校验其项目可访问, 返回 { task, project } */
export async function loadTaskForUser(taskId, userId) {
const id = Number(taskId)
if (!Number.isInteger(id) || id <= 0) return null
const task = await prisma.task.findFirst({
where: { id, project: { members: { some: { userId } } } },
include: {
column: true,
assignee: { select: { id: true, name: true } },
creator: { select: { id: true, name: true } },
members: { include: { user: { select: { id: true, name: true } } } },
tags: true,
children: {
include: {
column: true,
assignee: { select: { id: true, name: true } },
members: { include: { user: { select: { id: true, name: true } } } },
tags: true,
},
orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }],
},
},
})
if (!task) return null
const project = await prisma.project.findUnique({ where: { id: task.projectId } })
return { task, project }
}
/** 项目全部列 (按显示顺序) */
export async function loadColumns(projectId) {
return prisma.boardColumn.findMany({
where: { projectId: Number(projectId) },
orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }],
})
}
/** 项目成员 userId 集合 */
export async function loadMemberIds(projectId) {
const rows = await prisma.projectMember.findMany({
where: { projectId: Number(projectId) },
select: { userId: true },
})
return rows.map((r) => r.userId)
}
+89
View File
@@ -0,0 +1,89 @@
// 任务模块: 跨项目拷贝 (T-CP-*)
// 拷贝而非移动: 新建记录 + 深拷贝子任务, 源任务零改动.
import { prisma } from '../db.js'
/**
* 拷贝一条任务(含子任务)到目标项目。
*
* 规则:
* - 全字段复制: 标题/描述/状态/列/扩展卡片/标签 (T-CP-02)
* - 子任务一并复制, 且归属目标项目 (T-CP-03, T-CP-08)
* - creatorId = 操作者 (T-CP-04)
* - 负责人 / 任务成员只保留"属于目标项目成员"的部分, 其余丢弃并回报 (T-CP-07, T-EX-08)
* - 状态与列沿用源值, 不强行纠正 (T-EX-14)
*
* @returns {{ task, dropped: { assignee: null|{id,name}, members: {id,name}[] } }}
*/
export async function copyTask({ source, targetProject, targetColumns, operatorId, targetMemberIds }) {
const memberSet = new Set(targetMemberIds)
// 列映射: 目标项目里找同名列, 找不到就用第一列 (保证 columnId 合法)
const pickColumn = (sourceColumnId) => {
const srcCol = source.__columns?.find((c) => c.id === sourceColumnId)
if (srcCol) {
const same = targetColumns.find((c) => c.name === srcCol.name)
if (same) return same.id
}
return targetColumns[0]?.id
}
// 人员过滤
const keepAssignee = source.assigneeId && memberSet.has(source.assigneeId) ? source.assigneeId : null
const droppedMembers = []
const keptMemberIds = []
for (const m of source.members || []) {
if (memberSet.has(m.userId)) keptMemberIds.push(m.userId)
else droppedMembers.push(m.user)
}
const droppedAssignee = source.assigneeId && !memberSet.has(source.assigneeId) ? source.assignee : null
const baseData = (extra) => ({
projectId: targetProject.id,
title: source.title,
description: source.description || '',
status: source.status,
columnId: extra.columnId,
parentId: extra.parentId ?? null,
assigneeId: extra.parentId ? (source.assigneeId && memberSet.has(source.assigneeId) ? source.assigneeId : null) : keepAssignee,
creatorId: operatorId,
extras: source.extras ?? [],
doneAt: source.status === 'done' ? (source.doneAt || new Date()) : null,
sortOrder: source.sortOrder ?? 0,
tags: {
create: (source.tags || []).map((t) => ({ name: t.name })),
},
})
const newTask = await prisma.task.create({
data: {
...baseData({ columnId: pickColumn(source.columnId) }),
members: {
create: keptMemberIds.map((uid) => ({ userId: uid })),
},
},
})
// 子任务: 同法复制, parentId 指向新父任务
for (const child of source.children || []) {
const childMembers = (child.members || [])
.filter((m) => memberSet.has(m.userId))
.map((m) => m.userId)
await prisma.task.create({
data: {
...baseData({
columnId: pickColumn(child.columnId),
parentId: newTask.id,
}),
members: { create: childMembers.map((uid) => ({ userId: uid })) },
},
})
}
return {
task: newTask,
dropped: {
assignee: droppedAssignee ? { id: droppedAssignee.id, name: droppedAssignee.name } : null,
members: droppedMembers.map((u) => ({ id: u.id, name: u.name })),
},
}
}
+93
View File
@@ -0,0 +1,93 @@
// 任务模块: 自然日边界工具 (Asia/Shanghai)
// 与前端 src/pages/task/date.js 保持同一口径, 改动需同步
//
// 保留选项口径 (T-FL-02):
// never 永不显示
// today 当天 -> 今天 00:00
// 3d 3 天 -> 今天-2 天 00:00 (含今天共 3 个自然日)
// week 本周 -> 本周一 00:00
// month 本月 -> 本月 1 日 00:00
// forever 永久显示 -> 无下界
export const TZ = 'Asia/Shanghai'
export const RETENTION = ['never', 'today', '3d', 'week', 'month', 'forever']
export const RETENTION_LABEL = {
never: '永不显示',
today: '当天',
'3d': '3 天',
week: '本周',
month: '本月',
forever: '永久显示',
}
export const STATUSES = ['todo', 'doing', 'done']
export const STATUS_LABEL = { todo: '未开始', doing: '执行中', done: '完成' }
// 当前时刻在 Asia/Shanghai 的 "YYYY-MM-DD" 与星期几
const FMT = new Intl.DateTimeFormat('en-CA', {
timeZone: TZ, year: 'numeric', month: '2-digit', day: '2-digit',
})
/** 任意时刻 -> 本地日历日 "YYYY-MM-DD" */
export function toDateStr(d = new Date()) {
return FMT.format(d instanceof Date ? d : new Date(d))
}
/** "YYYY-MM-DD" -> UTC 零点 Date (仅做日期运算) */
function toUTC(s) {
const [y, m, d] = String(s).split('-').map(Number)
return new Date(Date.UTC(y, (m || 1) - 1, d || 1))
}
function fromUTC(dt) {
return dt.toISOString().slice(0, 10)
}
export function addDays(s, n) {
const d = toUTC(s)
d.setUTCDate(d.getUTCDate() + n)
return fromUTC(d)
}
/** 周一为一周第一天 */
export function startOfWeek(s) {
const dow = (toUTC(s).getUTCDay() + 6) % 7
return addDays(s, -dow)
}
export function startOfMonth(s) {
return String(s).slice(0, 8) + '01'
}
/**
* 保留选项 -> 起始时间(不含时区偏移的 Date, 以 UTC 表示当日零点)
* 返回 null 表示"全可见"(forever); 返回 'never' 表示"全隐藏"
*/
export function retentionStart(option, now = new Date()) {
const today = toDateStr(now)
switch (option) {
case 'never': return { mode: 'never' }
case 'forever': return { mode: 'all' }
case 'today': return { mode: 'since', date: today }
case '3d': return { mode: 'since', date: addDays(today, -2) }
case 'week': return { mode: 'since', date: startOfWeek(today) }
case 'month': return { mode: 'since', date: startOfMonth(today) }
default: return { mode: 'all' }
}
}
/**
* 判断一条已完成任务在给定保留选项下是否可见。
* 依据任务的完成时刻 doneAt (T-FL-10); 未完成任务恒可见 (T-FL-12)。
*/
export function isVisibleByRetention(task, option, now = new Date()) {
if (task.status !== 'done') return true
const win = retentionStart(option, now)
if (win.mode === 'never') return false
if (win.mode === 'all') return true
if (!task.doneAt) return false
// doneAt 是绝对时刻, 转成本地日历日再与窗口起点比较
const doneDay = toDateStr(task.doneAt)
return doneDay >= win.date
}
+85
View File
@@ -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 }
}
+31
View File
@@ -0,0 +1,31 @@
// 任务模块: 保留选项的可见性过滤 (T-FL-*)
// 前端 src/pages/task/date.js 有一份同口径实现, 用于即时过滤; 后端用于统计/兜底
import { isVisibleByRetention } from './date.js'
/**
* 按"任务所属列"的保留选项过滤任务列表 (T-FL-11)
* @param {object[]} tasks 任务(需含 status / doneAt / columnId)
* @param {object[]} columns 列(需含 id / retention)
*/
export function filterVisible(tasks, columns, now = new Date()) {
const retentionById = new Map((columns || []).map((c) => [c.id, c.retention || 'forever']))
return (tasks || []).filter((t) => {
const opt = retentionById.get(t.columnId) || 'forever'
return isVisibleByRetention(t, opt, now)
})
}
/** 单条判定, 供详情接口复用 */
export function isTaskVisible(task, columns, now = new Date()) {
const col = (columns || []).find((c) => c.id === task.columnId)
return isVisibleByRetention(task, col?.retention || 'forever', now)
}
/** 列内计数: 过滤后条数 (T-FL-14 计数与展示同源) */
export function countByColumn(tasks, columns, now = new Date()) {
const visible = filterVisible(tasks, columns, now)
const out = {}
for (const c of columns || []) out[c.id] = 0
for (const t of visible) out[t.columnId] = (out[t.columnId] || 0) + 1
return out
}