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:
@@ -14,6 +14,8 @@ import monitorsRouter from './routes/monitors.js'
|
||||
import mihomeRouter from './routes/mihome.js'
|
||||
import checkinRouter from './routes/checkin.js'
|
||||
import checklistsRouter from './routes/checklists.js'
|
||||
import projectsRouter, { columnRouter } from './routes/projects.js'
|
||||
import tasksRouter from './routes/tasks.js'
|
||||
|
||||
const PORT = Number(process.env.PORT) || 3001
|
||||
const DIST_DIR = path.join(process.cwd(), 'dist')
|
||||
@@ -41,6 +43,10 @@ app.use('/api/mihome', mihomeRouter)
|
||||
// V0.2 打卡 / 日历 / 清单
|
||||
app.use('/api/checkin', checkinRouter)
|
||||
app.use('/api/checklists', checklistsRouter)
|
||||
// V0.2 任务模块: 项目 / 列 / 任务
|
||||
app.use('/api/projects', projectsRouter)
|
||||
app.use('/api/columns', columnRouter)
|
||||
app.use('/api', tasksRouter)
|
||||
|
||||
// 静态资源 (Vite build 产物)
|
||||
if (HAS_DIST) {
|
||||
|
||||
@@ -0,0 +1,328 @@
|
||||
// 任务模块: 项目 / 成员 / 看板列
|
||||
import { Router } from 'express'
|
||||
import { prisma } from '../db.js'
|
||||
import { authRequired } from '../middleware.js'
|
||||
import { loadProjectForUser, loadColumns, loadMemberIds } from '../task-domain/access.js'
|
||||
|
||||
const router = Router()
|
||||
router.use(authRequired)
|
||||
|
||||
// 新建项目时的默认 4 列 (T-COL-01, T-BD-09)
|
||||
export const DEFAULT_COLUMNS = [
|
||||
{ name: 'Plan', sortOrder: 0, boundStatus: 'todo' },
|
||||
{ name: 'Doing', sortOrder: 1, boundStatus: 'doing' },
|
||||
{ name: 'Action', sortOrder: 2, boundStatus: null },
|
||||
{ name: 'Done', sortOrder: 3, boundStatus: 'done' },
|
||||
]
|
||||
|
||||
export const MAX_COLUMNS = 10
|
||||
const MAX_NAME = 20
|
||||
|
||||
/** 建 4 个默认列 */
|
||||
async function createDefaultColumns(projectId) {
|
||||
await prisma.boardColumn.createMany({
|
||||
data: DEFAULT_COLUMNS.map((c) => ({ ...c, projectId, retention: 'forever' })),
|
||||
})
|
||||
}
|
||||
|
||||
/** 确保用户有一个个人默认项目 (T-PJ-01), 幂等 */
|
||||
export async function ensurePersonalProject(userId) {
|
||||
const exist = await prisma.project.findFirst({ where: { ownerId: userId, isPersonal: true } })
|
||||
if (exist) return exist
|
||||
const user = await prisma.user.findUnique({ where: { id: userId }, select: { name: true } })
|
||||
const name = `${user?.name || '我'}的任务`
|
||||
const project = await prisma.project.create({
|
||||
data: { name, ownerId: userId, isPersonal: true, members: { create: { userId } } },
|
||||
})
|
||||
await createDefaultColumns(project.id)
|
||||
return project
|
||||
}
|
||||
|
||||
function projectSummary(p) {
|
||||
return {
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
ownerId: p.ownerId,
|
||||
isPersonal: p.isPersonal,
|
||||
archived: p.archived,
|
||||
createdAt: p.createdAt,
|
||||
updatedAt: p.updatedAt,
|
||||
memberCount: p._count?.members ?? undefined,
|
||||
taskCount: p._count?.tasks ?? undefined,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 项目 ----------
|
||||
|
||||
// GET /api/projects 我参与的项目
|
||||
router.get('/', async (req, res, next) => {
|
||||
try {
|
||||
const userId = req.user.sub
|
||||
await ensurePersonalProject(userId)
|
||||
const rows = await prisma.project.findMany({
|
||||
where: { members: { some: { userId } } },
|
||||
include: { _count: { select: { members: true, tasks: true } } },
|
||||
orderBy: [{ archived: 'asc' }, { updatedAt: 'desc' }],
|
||||
})
|
||||
res.json({ items: rows.map(projectSummary) })
|
||||
} catch (e) { next(e) }
|
||||
})
|
||||
|
||||
// POST /api/projects { name }
|
||||
router.post('/', async (req, res, next) => {
|
||||
try {
|
||||
const userId = req.user.sub
|
||||
const name = String(req.body?.name || '').trim()
|
||||
if (!name) return res.status(400).json({ error: '项目名称不能为空' })
|
||||
if (name.length > MAX_NAME) return res.status(400).json({ error: `项目名称不能超过 ${MAX_NAME} 字` })
|
||||
|
||||
const project = await prisma.project.create({
|
||||
data: { name, ownerId: userId, isPersonal: false, members: { create: { userId } } },
|
||||
})
|
||||
await createDefaultColumns(project.id)
|
||||
const full = await prisma.project.findUnique({
|
||||
where: { id: project.id },
|
||||
include: { _count: { select: { members: true, tasks: true } } },
|
||||
})
|
||||
res.json({ item: projectSummary(full) })
|
||||
} catch (e) { next(e) }
|
||||
})
|
||||
|
||||
// GET /api/projects/:id 详情(项目 + 成员 + 列)
|
||||
router.get('/:id', async (req, res, next) => {
|
||||
try {
|
||||
const userId = req.user.sub
|
||||
const project = await loadProjectForUser(req.params.id, userId)
|
||||
if (!project) return res.status(404).json({ error: '项目不存在或无权限' })
|
||||
const [members, columns] = await Promise.all([
|
||||
prisma.projectMember.findMany({
|
||||
where: { projectId: project.id },
|
||||
include: { user: { select: { id: true, name: true, email: true } } },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
}),
|
||||
loadColumns(project.id),
|
||||
])
|
||||
res.json({
|
||||
item: {
|
||||
...projectSummary(project),
|
||||
members: members.map((m) => ({ userId: m.userId, name: m.user.name, email: m.user.email, permission: m.permission })),
|
||||
columns,
|
||||
},
|
||||
})
|
||||
} catch (e) { next(e) }
|
||||
})
|
||||
|
||||
// PUT /api/projects/:id { name?, archived? }
|
||||
router.put('/:id', async (req, res, next) => {
|
||||
try {
|
||||
const userId = req.user.sub
|
||||
const project = await loadProjectForUser(req.params.id, userId)
|
||||
if (!project) return res.status(404).json({ error: '项目不存在或无权限' })
|
||||
const { name, archived } = req.body || {}
|
||||
const data = {}
|
||||
if (name !== undefined) {
|
||||
const t = String(name).trim()
|
||||
if (!t) return res.status(400).json({ error: '项目名称不能为空' })
|
||||
if (t.length > MAX_NAME) return res.status(400).json({ error: `项目名称不能超过 ${MAX_NAME} 字` })
|
||||
data.name = t
|
||||
}
|
||||
if (archived !== undefined) data.archived = !!archived
|
||||
const row = await prisma.project.update({ where: { id: project.id }, data })
|
||||
res.json({ item: projectSummary(row) })
|
||||
} catch (e) { next(e) }
|
||||
})
|
||||
|
||||
// DELETE /api/projects/:id 个人项目拒绝 (T-PJ-02, T-EX-09)
|
||||
router.delete('/:id', async (req, res, next) => {
|
||||
try {
|
||||
const userId = req.user.sub
|
||||
const project = await loadProjectForUser(req.params.id, userId)
|
||||
if (!project) return res.status(404).json({ error: '项目不存在或无权限' })
|
||||
if (project.isPersonal) return res.status(400).json({ error: '个人默认项目不可删除' })
|
||||
await prisma.project.delete({ where: { id: project.id } })
|
||||
res.json({ ok: true })
|
||||
} catch (e) { next(e) }
|
||||
})
|
||||
|
||||
// ---------- 成员 ----------
|
||||
|
||||
// POST /api/projects/:id/members { userId } | { email }
|
||||
router.post('/:id/members', async (req, res, next) => {
|
||||
try {
|
||||
const userId = req.user.sub
|
||||
const project = await loadProjectForUser(req.params.id, userId)
|
||||
if (!project) return res.status(404).json({ error: '项目不存在或无权限' })
|
||||
if (project.isPersonal) return res.status(400).json({ error: '个人默认项目不可添加成员' })
|
||||
|
||||
const { userId: targetId, email } = req.body || {}
|
||||
let target = null
|
||||
if (targetId) target = await prisma.user.findUnique({ where: { id: Number(targetId) } })
|
||||
else if (email) target = await prisma.user.findFirst({ where: { email: String(email).trim() } })
|
||||
if (!target) return res.status(404).json({ error: '用户不存在' })
|
||||
|
||||
const exist = await prisma.projectMember.findUnique({
|
||||
where: { projectId_userId: { projectId: project.id, userId: target.id } },
|
||||
})
|
||||
if (exist) return res.status(409).json({ error: '该用户已是项目成员' })
|
||||
|
||||
await prisma.projectMember.create({ data: { projectId: project.id, userId: target.id } })
|
||||
res.json({ ok: true, member: { userId: target.id, name: target.name } })
|
||||
} catch (e) { next(e) }
|
||||
})
|
||||
|
||||
// DELETE /api/projects/:id/members/:uid 移除成员 / 自己退出 (T-PJ-06/07)
|
||||
router.delete('/:id/members/:uid', async (req, res, next) => {
|
||||
try {
|
||||
const userId = req.user.sub
|
||||
const project = await loadProjectForUser(req.params.id, userId)
|
||||
if (!project) return res.status(404).json({ error: '项目不存在或无权限' })
|
||||
if (project.isPersonal) return res.status(400).json({ error: '个人默认项目不可移除成员' })
|
||||
|
||||
const target = Number(req.params.uid)
|
||||
// 只能移除自己, 或是创建人移除他人 (本期权限均等, 允许成员自退)
|
||||
if (target !== userId && project.ownerId !== userId) {
|
||||
return res.status(403).json({ error: '只能移除自己,或由创建人移除他人' })
|
||||
}
|
||||
await prisma.projectMember.deleteMany({ where: { projectId: project.id, userId: target } })
|
||||
// 成员清空后项目静默保留 (T-PJ-08)
|
||||
res.json({ ok: true })
|
||||
} catch (e) { next(e) }
|
||||
})
|
||||
|
||||
// ---------- 看板列 ----------
|
||||
|
||||
// GET /api/projects/:id/columns
|
||||
router.get('/:id/columns', async (req, res, next) => {
|
||||
try {
|
||||
const userId = req.user.sub
|
||||
const project = await loadProjectForUser(req.params.id, userId)
|
||||
if (!project) return res.status(404).json({ error: '项目不存在或无权限' })
|
||||
res.json({ items: await loadColumns(project.id) })
|
||||
} catch (e) { next(e) }
|
||||
})
|
||||
|
||||
// POST /api/projects/:id/columns { name }
|
||||
router.post('/:id/columns', async (req, res, next) => {
|
||||
try {
|
||||
const userId = req.user.sub
|
||||
const project = await loadProjectForUser(req.params.id, userId)
|
||||
if (!project) return res.status(404).json({ error: '项目不存在或无权限' })
|
||||
|
||||
const name = String(req.body?.name || '').trim()
|
||||
if (!name) return res.status(400).json({ error: '列名不能为空' })
|
||||
if (name.length > MAX_NAME) return res.status(400).json({ error: `列名不能超过 ${MAX_NAME} 字` })
|
||||
|
||||
const cols = await loadColumns(project.id)
|
||||
if (cols.length >= MAX_COLUMNS) return res.status(400).json({ error: `最多 ${MAX_COLUMNS} 列` })
|
||||
if (cols.some((c) => c.name === name)) return res.status(409).json({ error: '列名已存在' })
|
||||
|
||||
const row = await prisma.boardColumn.create({
|
||||
data: {
|
||||
projectId: project.id,
|
||||
name,
|
||||
sortOrder: cols.length ? Math.max(...cols.map((c) => c.sortOrder)) + 1 : 0,
|
||||
boundStatus: null,
|
||||
retention: 'forever',
|
||||
},
|
||||
})
|
||||
res.json({ item: row })
|
||||
} catch (e) { next(e) }
|
||||
})
|
||||
|
||||
// PUT /api/projects/:id/columns/order { ids: number[] }
|
||||
router.put('/:id/columns/order', async (req, res, next) => {
|
||||
try {
|
||||
const userId = req.user.sub
|
||||
const project = await loadProjectForUser(req.params.id, userId)
|
||||
if (!project) return res.status(404).json({ error: '项目不存在或无权限' })
|
||||
const ids = Array.isArray(req.body?.ids) ? req.body.ids.map(Number) : []
|
||||
const cols = await loadColumns(project.id)
|
||||
if (ids.length !== cols.length) return res.status(400).json({ error: '列数量不匹配' })
|
||||
|
||||
await prisma.$transaction(
|
||||
ids.map((id, idx) => prisma.boardColumn.update({ where: { id }, data: { sortOrder: idx } })),
|
||||
)
|
||||
res.json({ items: await loadColumns(project.id) })
|
||||
} catch (e) { next(e) }
|
||||
})
|
||||
|
||||
// PUT /api/columns/:id { name?, boundStatus?, retention? }
|
||||
export const columnRouter = Router()
|
||||
columnRouter.use(authRequired)
|
||||
|
||||
columnRouter.put('/:id', async (req, res, next) => {
|
||||
try {
|
||||
const userId = req.user.sub
|
||||
const id = Number(req.params.id)
|
||||
const col = await prisma.boardColumn.findUnique({ where: { id } })
|
||||
if (!col) return res.status(404).json({ error: '列不存在' })
|
||||
const project = await loadProjectForUser(col.projectId, userId)
|
||||
if (!project) return res.status(404).json({ error: '项目不存在或无权限' })
|
||||
|
||||
const { name, boundStatus, retention } = req.body || {}
|
||||
const data = {}
|
||||
if (name !== undefined) {
|
||||
const t = String(name).trim()
|
||||
if (!t) return res.status(400).json({ error: '列名不能为空' })
|
||||
if (t.length > MAX_NAME) return res.status(400).json({ error: `列名不能超过 ${MAX_NAME} 字` })
|
||||
const dup = await prisma.boardColumn.findFirst({ where: { projectId: col.projectId, name: t, NOT: { id } } })
|
||||
if (dup) return res.status(409).json({ error: '列名已存在' })
|
||||
data.name = t
|
||||
}
|
||||
if (boundStatus !== undefined) {
|
||||
// null | todo | doing | done
|
||||
if (boundStatus !== null && !['todo', 'doing', 'done'].includes(boundStatus)) {
|
||||
return res.status(400).json({ error: '绑定状态取值非法' })
|
||||
}
|
||||
data.boundStatus = boundStatus
|
||||
}
|
||||
if (retention !== undefined) {
|
||||
if (!['never', 'today', '3d', 'week', 'month', 'forever'].includes(retention)) {
|
||||
return res.status(400).json({ error: '保留选项取值非法' })
|
||||
}
|
||||
data.retention = retention
|
||||
}
|
||||
const row = await prisma.boardColumn.update({ where: { id }, data })
|
||||
res.json({ item: row })
|
||||
} catch (e) { next(e) }
|
||||
})
|
||||
|
||||
// DELETE /api/columns/:id?moveToColumnId=N 删列并迁移任务 (T-COL-04, T-EX-16/17)
|
||||
columnRouter.delete('/:id', async (req, res, next) => {
|
||||
try {
|
||||
const userId = req.user.sub
|
||||
const id = Number(req.params.id)
|
||||
const col = await prisma.boardColumn.findUnique({ where: { id } })
|
||||
if (!col) return res.status(404).json({ error: '列不存在' })
|
||||
const project = await loadProjectForUser(col.projectId, userId)
|
||||
if (!project) return res.status(404).json({ error: '项目不存在或无权限' })
|
||||
|
||||
const cols = await loadColumns(col.projectId)
|
||||
if (cols.length <= 1) return res.status(400).json({ error: '至少保留一列' })
|
||||
|
||||
// 迁移目标: 指定列 否则相邻列(优先后一个, 没有则前一个)
|
||||
let targetId = Number(req.query.moveToColumnId) || 0
|
||||
if (!targetId) {
|
||||
const idx = cols.findIndex((c) => c.id === id)
|
||||
targetId = (cols[idx + 1] || cols[idx - 1]).id
|
||||
}
|
||||
const target = cols.find((c) => c.id === targetId)
|
||||
if (!target || target.id === id) return res.status(400).json({ error: '迁移目标列非法' })
|
||||
|
||||
// 迁移任务: 状态按目标列绑定重新裁决 (设计文档 §2.4)
|
||||
const tasks = await prisma.task.findMany({ where: { columnId: id } })
|
||||
const newStatus = target.boundStatus || null
|
||||
await prisma.task.updateMany({
|
||||
where: { columnId: id },
|
||||
data: {
|
||||
columnId: target.id,
|
||||
...(newStatus ? { status: newStatus } : {}),
|
||||
},
|
||||
})
|
||||
await prisma.boardColumn.delete({ where: { id } })
|
||||
res.json({ ok: true, moved: tasks.length, toColumnId: target.id })
|
||||
} catch (e) { next(e) }
|
||||
})
|
||||
|
||||
export { createDefaultColumns, loadMemberIds }
|
||||
export default router
|
||||
@@ -0,0 +1,284 @@
|
||||
// 任务模块: 任务 / 子任务 / 跨项目拷贝
|
||||
import { Router } from 'express'
|
||||
import { prisma } from '../db.js'
|
||||
import { authRequired } from '../middleware.js'
|
||||
import {
|
||||
loadTaskForUser, loadProjectForUser, loadColumns, loadMemberIds,
|
||||
} from '../task-domain/access.js'
|
||||
import { resolveTaskWrite } from '../task-domain/resolveStatus.js'
|
||||
import { filterVisible } from '../task-domain/retention.js'
|
||||
import { copyTask } from '../task-domain/copyTask.js'
|
||||
|
||||
const router = Router()
|
||||
router.use(authRequired)
|
||||
|
||||
const TASK_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' }],
|
||||
},
|
||||
}
|
||||
|
||||
// 归一化: members / tags 平铺为数组, 便于前端直接用
|
||||
function shape(task) {
|
||||
if (!task) return task
|
||||
const out = {
|
||||
...task,
|
||||
members: (task.members || []).map((m) => ({ userId: m.userId, name: m.user?.name })),
|
||||
tags: (task.tags || []).map((t) => t.name),
|
||||
}
|
||||
if (Array.isArray(task.children)) out.children = task.children.map(shape)
|
||||
return out
|
||||
}
|
||||
|
||||
// ---------- 项目下的任务列表 ----------
|
||||
|
||||
// GET /api/projects/:id/tasks
|
||||
router.get('/projects/:id/tasks', async (req, res, next) => {
|
||||
try {
|
||||
const userId = req.user.sub
|
||||
const project = await loadProjectForUser(req.params.id, userId)
|
||||
if (!project) return res.status(404).json({ error: '项目不存在或无权限' })
|
||||
|
||||
const columns = await loadColumns(project.id)
|
||||
const rows = await prisma.task.findMany({
|
||||
where: { projectId: project.id, parentId: null },
|
||||
include: TASK_INCLUDE,
|
||||
orderBy: [{ sortOrder: 'asc' }, { createdAt: 'asc' }],
|
||||
})
|
||||
const shaped = rows.map(shape)
|
||||
// 保留选项过滤 (T-FL-*): 已完成任务按所属列配置隐藏
|
||||
const visible = filterVisible(shaped, columns)
|
||||
res.json({ items: visible, columns, total: shaped.length })
|
||||
} catch (e) { next(e) }
|
||||
})
|
||||
|
||||
// POST /api/projects/:id/tasks { title, description?, parentId?, assigneeId?, memberIds?, tags? }
|
||||
router.post('/projects/:id/tasks', async (req, res, next) => {
|
||||
try {
|
||||
const userId = req.user.sub
|
||||
const project = await loadProjectForUser(req.params.id, userId)
|
||||
if (!project) return res.status(404).json({ error: '项目不存在或无权限' })
|
||||
|
||||
const { title, description = '', parentId, assigneeId, memberIds = [], tags = [] } = req.body || {}
|
||||
const cleanTitle = String(title || '').trim()
|
||||
if (!cleanTitle) return res.status(400).json({ error: '任务标题不能为空' })
|
||||
if (cleanTitle.length > 200) return res.status(400).json({ error: '任务标题不能超过 200 字' })
|
||||
|
||||
const columns = await loadColumns(project.id)
|
||||
if (!columns.length) return res.status(400).json({ error: '项目没有可用的看板列' })
|
||||
|
||||
// 子任务校验: 父任务必须存在、同项目、且本身是顶层任务 (T-ST-01/02, T-EX-12)
|
||||
let pid = null
|
||||
if (parentId) {
|
||||
const parent = await prisma.task.findUnique({ where: { id: Number(parentId) } })
|
||||
if (!parent) return res.status(404).json({ error: '父任务不存在' })
|
||||
if (parent.projectId !== project.id) return res.status(400).json({ error: '子任务必须与父任务同项目' })
|
||||
if (parent.parentId) return res.status(400).json({ error: '仅支持一级子任务' })
|
||||
pid = parent.id
|
||||
}
|
||||
|
||||
const memberIds_ = await loadMemberIds(project.id)
|
||||
const memberSet = new Set(memberIds_)
|
||||
// 负责人: 未指定 => 默认创建者本人 (T-TK-09); 显式 null => 不指派
|
||||
let finalAssignee = null
|
||||
if (assigneeId === undefined) finalAssignee = memberSet.has(userId) ? userId : null
|
||||
else if (assigneeId !== null) {
|
||||
const aid = Number(assigneeId)
|
||||
if (!memberSet.has(aid)) return res.status(400).json({ error: '负责人必须是项目成员' })
|
||||
finalAssignee = aid
|
||||
}
|
||||
|
||||
const firstCol = columns[0]
|
||||
const row = await prisma.task.create({
|
||||
data: {
|
||||
projectId: project.id,
|
||||
columnId: firstCol.id,
|
||||
parentId: pid,
|
||||
title: cleanTitle,
|
||||
description: String(description || ''),
|
||||
status: firstCol.boundStatus || 'todo',
|
||||
assigneeId: finalAssignee,
|
||||
creatorId: userId,
|
||||
extras: [],
|
||||
members: {
|
||||
create: (Array.isArray(memberIds) ? memberIds : [])
|
||||
.map(Number).filter((id) => memberSet.has(id))
|
||||
.map((uid) => ({ userId: uid })),
|
||||
},
|
||||
tags: { create: (Array.isArray(tags) ? tags : []).filter(Boolean).map((n) => ({ name: String(n).slice(0, 20) })) },
|
||||
},
|
||||
include: TASK_INCLUDE,
|
||||
})
|
||||
res.json({ item: shape(row) })
|
||||
} catch (e) { next(e) }
|
||||
})
|
||||
|
||||
// ---------- 单条任务 ----------
|
||||
|
||||
// GET /api/tasks/:id
|
||||
router.get('/tasks/:id', async (req, res, next) => {
|
||||
try {
|
||||
const userId = req.user.sub
|
||||
const found = await loadTaskForUser(req.params.id, userId)
|
||||
if (!found) return res.status(404).json({ error: '任务不存在或无权限' })
|
||||
res.json({ item: shape(found.task) })
|
||||
} catch (e) { next(e) }
|
||||
})
|
||||
|
||||
// PUT /api/tasks/:id 关键: 列/状态联动裁决
|
||||
router.put('/tasks/:id', async (req, res, next) => {
|
||||
try {
|
||||
const userId = req.user.sub
|
||||
const found = await loadTaskForUser(req.params.id, userId)
|
||||
if (!found) return res.status(404).json({ error: '任务不存在或无权限' })
|
||||
const { task, project } = found
|
||||
const body = req.body || {}
|
||||
|
||||
const columns = await loadColumns(project.id)
|
||||
const data = {}
|
||||
|
||||
// 文本 / 扩展信息
|
||||
if (body.title !== undefined) {
|
||||
const t = String(body.title).trim()
|
||||
if (!t) return res.status(400).json({ error: '任务标题不能为空' })
|
||||
if (t.length > 200) return res.status(400).json({ error: '任务标题不能超过 200 字' })
|
||||
data.title = t
|
||||
}
|
||||
if (body.description !== undefined) data.description = String(body.description)
|
||||
if (body.extras !== undefined) {
|
||||
if (!Array.isArray(body.extras)) return res.status(400).json({ error: '扩展信息格式非法' })
|
||||
data.extras = body.extras.map((e) => ({
|
||||
label: String(e?.label ?? '').slice(0, 40),
|
||||
value: String(e?.value ?? ''),
|
||||
url: e?.url ? String(e.url) : '',
|
||||
}))
|
||||
}
|
||||
if (body.sortOrder !== undefined) data.sortOrder = Number(body.sortOrder) || 0
|
||||
|
||||
// 负责人: 必须是项目成员或 null (T-AS-03)
|
||||
const memberIds = await loadMemberIds(project.id)
|
||||
const memberSet = new Set(memberIds)
|
||||
if (body.assigneeId !== undefined) {
|
||||
if (body.assigneeId === null || body.assigneeId === '') data.assigneeId = null
|
||||
else {
|
||||
const aid = Number(body.assigneeId)
|
||||
if (!memberSet.has(aid)) return res.status(400).json({ error: '负责人必须是项目成员' })
|
||||
data.assigneeId = aid
|
||||
}
|
||||
}
|
||||
|
||||
// 列 / 状态: 走统一裁决 (T-BD-*)
|
||||
const patch = {}
|
||||
if (body.columnId !== undefined) patch.columnId = Number(body.columnId)
|
||||
if (body.status !== undefined) {
|
||||
if (!['todo', 'doing', 'done'].includes(body.status)) return res.status(400).json({ error: '任务状态取值非法' })
|
||||
patch.status = body.status
|
||||
}
|
||||
if (patch.columnId !== undefined && !columns.some((c) => c.id === patch.columnId)) {
|
||||
return res.status(400).json({ error: '看板列不存在' })
|
||||
}
|
||||
|
||||
let finalStatus = task.status
|
||||
if (Object.keys(patch).length) {
|
||||
const r = resolveTaskWrite(task, patch, columns)
|
||||
data.status = r.status
|
||||
data.columnId = r.columnId
|
||||
finalStatus = r.status
|
||||
}
|
||||
|
||||
// 完成时间: 进入 done 写 now, 退出 done 置空 (T-FL-10)
|
||||
if (finalStatus === 'done' && task.status !== 'done') data.doneAt = new Date()
|
||||
else if (finalStatus !== 'done' && data.status !== undefined) data.doneAt = null
|
||||
|
||||
// 成员 / 标签: 整体替换
|
||||
if (Array.isArray(body.memberIds)) {
|
||||
const ids = body.memberIds.map(Number).filter((id) => memberSet.has(id))
|
||||
await prisma.taskMember.deleteMany({ where: { taskId: task.id } })
|
||||
if (ids.length) {
|
||||
await prisma.taskMember.createMany({
|
||||
data: ids.map((uid) => ({ taskId: task.id, userId: uid })),
|
||||
})
|
||||
}
|
||||
}
|
||||
if (Array.isArray(body.tags)) {
|
||||
await prisma.taskTag.deleteMany({ where: { taskId: task.id } })
|
||||
const names = body.tags.map((n) => String(n).trim()).filter(Boolean).slice(0, 10)
|
||||
if (names.length) {
|
||||
await prisma.taskTag.createMany({ data: names.map((name) => ({ taskId: task.id, name: name.slice(0, 20) })) })
|
||||
}
|
||||
}
|
||||
|
||||
// 拖拽降为子任务 (T-ST-12~15)
|
||||
if (body.parentId !== undefined) {
|
||||
if (body.parentId === null) {
|
||||
data.parentId = null
|
||||
} else {
|
||||
const pid = Number(body.parentId)
|
||||
if (pid === task.id) return res.status(400).json({ error: '不能成为自己的子任务' })
|
||||
const parent = await prisma.task.findUnique({ where: { id: pid } })
|
||||
if (!parent) return res.status(404).json({ error: '目标任务不存在' })
|
||||
if (parent.projectId !== task.projectId) return res.status(400).json({ error: '子任务必须与父任务同项目' })
|
||||
if (parent.parentId) return res.status(400).json({ error: '仅支持一级子任务' })
|
||||
const childCount = await prisma.task.count({ where: { parentId: task.id } })
|
||||
if (childCount > 0) return res.status(400).json({ error: '该任务已有子任务,不能成为子任务' })
|
||||
data.parentId = pid
|
||||
}
|
||||
}
|
||||
|
||||
const row = await prisma.task.update({ where: { id: task.id }, data, include: TASK_INCLUDE })
|
||||
res.json({ item: shape(row) })
|
||||
} catch (e) { next(e) }
|
||||
})
|
||||
|
||||
// DELETE /api/tasks/:id 级联删除子任务 (T-ST-11, T-EX-07)
|
||||
router.delete('/tasks/:id', async (req, res, next) => {
|
||||
try {
|
||||
const userId = req.user.sub
|
||||
const found = await loadTaskForUser(req.params.id, userId)
|
||||
if (!found) return res.status(404).json({ error: '任务不存在或无权限' })
|
||||
const childCount = await prisma.task.count({ where: { parentId: found.task.id } })
|
||||
await prisma.task.delete({ where: { id: found.task.id } })
|
||||
res.json({ ok: true, deletedSubtasks: childCount })
|
||||
} catch (e) { next(e) }
|
||||
})
|
||||
|
||||
// POST /api/tasks/:id/copy { projectId } 跨项目拷贝 (T-CP-*)
|
||||
router.post('/tasks/:id/copy', async (req, res, next) => {
|
||||
try {
|
||||
const userId = req.user.sub
|
||||
const found = await loadTaskForUser(req.params.id, userId)
|
||||
if (!found) return res.status(404).json({ error: '任务不存在或无权限' })
|
||||
|
||||
const targetId = Number(req.body?.projectId)
|
||||
const targetProject = await loadProjectForUser(targetId, userId)
|
||||
if (!targetProject) return res.status(403).json({ error: '目标项目不存在或你不是其成员' })
|
||||
|
||||
const targetColumns = await loadColumns(targetProject.id)
|
||||
if (!targetColumns.length) return res.status(400).json({ error: '目标项目没有可用的看板列' })
|
||||
const targetMemberIds = await loadMemberIds(targetProject.id)
|
||||
|
||||
// 源任务需带列名映射信息
|
||||
const sourceColumns = await loadColumns(found.project.id)
|
||||
const source = { ...found.task, __columns: sourceColumns }
|
||||
|
||||
const result = await copyTask({
|
||||
source, targetProject, targetColumns, operatorId: userId, targetMemberIds,
|
||||
})
|
||||
|
||||
const fresh = await prisma.task.findUnique({ where: { id: result.task.id }, include: TASK_INCLUDE })
|
||||
res.json({ item: shape(fresh), dropped: result.dropped })
|
||||
} catch (e) { next(e) }
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 })),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user