fix(任务): 归档以整棵树为单位并移除隐藏中间态
Docker Build and Push / build-image (push) Successful in 1m42s

- 自动归档改为 doneAt + autoArchive 到期,只判定顶层任务,到期整树归档
- 手动归档/恢复提升到根父任务,子任务完全跟随父任务
- 新增 normalizeArchiveTree 修复历史父子归档不一致
- 移除按列保留窗口的显示过滤(前后端实现删除,T-FL-01~15 废止)
- 列 retention 字段仅兼容旧数据保留,不再参与判定
- 已归档查询返回归档子任务,删除提示可正确显示连带条数
This commit is contained in:
jif2.zhang
2026-11-10 09:20:40 +08:00
parent 7b52220aad
commit e05ed6b491
15 changed files with 255 additions and 489 deletions
+3 -12
View File
@@ -17,7 +17,6 @@ import { tool, reqStr, parseMarkDate } from '../helpers.js'
const MAX_NAME = 50
const MAX_COLUMN_NAME = 20
const STATUSES = ['todo', 'doing', 'done']
const RETENTIONS = ['never', 'today', '3d', 'week', 'month', 'forever']
const TASK_INCLUDE = {
column: true,
@@ -294,16 +293,14 @@ function registerColumnTools(server, ctx) {
projectId: z.coerce.number().int().positive().describe('项目 ID'),
name: z.string().describe('列名, 最多 20 字'),
boundStatus: z.enum(STATUSES).nullable().optional().describe('绑定任务状态 todo/doing/done, null 表示不联动'),
retention: z.enum(RETENTIONS).optional().describe('完成保留选项, 默认 forever'),
},
}, async ({ projectId, name, boundStatus = null, retention = 'forever' }) => {
}, async ({ projectId, name, boundStatus = null }) => {
const project = await requireProject(projectId, userId)
const cleanName = reqStr(name, '列名', MAX_COLUMN_NAME)
const cols = await loadColumns(project.id)
if (cols.length >= MAX_COLUMNS) throw new Error(`最多 ${MAX_COLUMNS} 列`)
if (cols.some((c) => c.name === cleanName)) throw new Error('列名已存在')
if (boundStatus !== null && !STATUSES.includes(boundStatus)) throw new Error('绑定状态取值非法')
if (!RETENTIONS.includes(retention)) throw new Error('保留选项取值非法')
const row = await prisma.boardColumn.create({
data: {
@@ -312,7 +309,6 @@ function registerColumnTools(server, ctx) {
name: cleanName,
sortOrder: cols.length ? Math.max(...cols.map((c) => c.sortOrder)) + 1 : 0,
boundStatus,
retention,
},
})
return { item: row }
@@ -320,14 +316,13 @@ function registerColumnTools(server, ctx) {
tool(server, 'update_column', {
title: '修改看板列',
description: '修改列名、绑定状态或完成保留选项。',
description: '修改列名或绑定状态。',
inputSchema: {
columnId: z.coerce.number().int().positive().describe('看板列 ID'),
name: z.string().optional().describe('新列名, 最多 20 字'),
boundStatus: z.enum(STATUSES).nullable().optional().describe('绑定任务状态, null 表示取消绑定'),
retention: z.enum(RETENTIONS).optional().describe('完成保留选项'),
},
}, async ({ columnId, name, boundStatus, retention }) => {
}, async ({ columnId, name, boundStatus }) => {
const col = await prisma.boardColumn.findUnique({ where: { id: Number(columnId) } })
if (!col) throw new Error('列不存在')
const project = await requireProject(col.projectId, userId)
@@ -345,10 +340,6 @@ function registerColumnTools(server, ctx) {
if (boundStatus !== null && !STATUSES.includes(boundStatus)) throw new Error('绑定状态取值非法')
data.boundStatus = boundStatus
}
if (retention !== undefined) {
if (!RETENTIONS.includes(retention)) throw new Error('保留选项取值非法')
data.retention = retention
}
const row = await prisma.boardColumn.update({ where: { id: col.id }, data })
return { item: row }
})
+21 -11
View File
@@ -6,7 +6,6 @@ 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'
import { runAutoArchiveForProject } from '../task-domain/archive.js'
import { genUuid, withUuid, withTaskNumber } from '../task-domain/identity.js'
@@ -65,24 +64,31 @@ router.get('/projects/:id/tasks', async (req, res, next) => {
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 })
res.json({ items: shaped, columns, total: shaped.length })
} catch (e) { next(e) }
})
// ---------- 归档 (T-ARC) ----------
/**
* 归档/恢复以整棵树为单位: 子任务不单独变化。
* 即使调用方传的是子任务 id, 也提升到根父任务处理。
*/
async function loadTreeIds(task) {
const rootId = task.parentId ? task.parentId : task.id
const children = await prisma.task.findMany({ where: { parentId: rootId }, select: { id: true } })
return [rootId, ...children.map((c) => c.id)]
}
// POST /api/tasks/:id/archive 手动归档: 连带全部子任务, 幂等
router.post('/tasks/:id/archive', 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 } = found
const children = await prisma.task.findMany({ where: { parentId: task.id }, select: { id: true } })
const ids = await loadTreeIds(found.task)
const r = await prisma.task.updateMany({
where: { id: { in: [task.id, ...children.map((c) => c.id)] } },
where: { id: { in: ids } },
data: { archived: true, archivedAt: new Date(), archiveSource: 'manual' },
})
res.json({ ok: true, count: r.count })
@@ -95,10 +101,9 @@ router.post('/tasks/:id/unarchive', async (req, res, next) => {
const userId = req.user.sub
const found = await loadTaskForUser(req.params.id, userId)
if (!found) return res.status(404).json({ error: '任务不存在或无权限' })
const { task } = found
const children = await prisma.task.findMany({ where: { parentId: task.id }, select: { id: true } })
const ids = await loadTreeIds(found.task)
const r = await prisma.task.updateMany({
where: { id: { in: [task.id, ...children.map((c) => c.id)] } },
where: { id: { in: ids } },
data: { archived: false, archivedAt: null, archiveSource: null },
})
res.json({ ok: true, count: r.count })
@@ -135,7 +140,12 @@ router.get('/projects/:id/tasks/archived', async (req, res, next) => {
const rows = await prisma.task.findMany({
where,
include: TASK_INCLUDE,
// 已归档任务整树归档: 子任务也是 archived=true, 这里要一并取出,
// 否则「彻底删除」弹窗无法提示连带条数
include: {
...TASK_INCLUDE,
children: { ...TASK_INCLUDE.children, where: { archived: true } },
},
orderBy: [{ archivedAt: 'desc' }, { id: 'desc' }],
take: limit + 1,
})
+70 -65
View File
@@ -1,105 +1,110 @@
// 任务归档领域逻辑 (T-ARC)
// 自动归档判定: 倒计时起点 = 任务"被隐藏的时刻"(与 date.js isVisibleByRetention
// 精确同口径推导): 完成日 + 保留窗口后的上海零点; 加上项目 autoArchive 档位即到期时刻。
// 自动归档: 顶层任务完成后, 经过项目 autoArchive 档位即到期; 到期时把该任务
// 及其全部子任务作为一个整体归档。子任务不单独判定, 完全跟随父任务。
// 执行双保险: 项目任务加载时惰性执行 (runAutoArchiveForProject) + 每小时 cron 兜底。
import { prisma } from '../db.js'
import { toDateStr, addDays, startOfWeek, startOfMonth } from './date.js'
export const AUTO_ARCHIVE_OPTIONS = ['3d', '1w', '1m', 'never']
const DAY_MS = 24 * 3600 * 1000
const SHANGHAI_OFFSET_MS = 8 * 3600 * 1000
/** 上海日历日 "YYYY-MM-DD" 的零点时刻 (UTC 表示) */
function shanghaiMidnight(dayStr) {
const [y, m, d] = dayStr.split('-').map(Number)
return new Date(Date.UTC(y, m - 1, d) - SHANGHAI_OFFSET_MS)
/** 按上海日历月加 n 个月 (保留时刻), 用于 1m 档 */
function addMonthsShanghai(date, n) {
const shifted = new Date(date.getTime() + SHANGHAI_OFFSET_MS)
shifted.setUTCMonth(shifted.getUTCMonth() + n)
return new Date(shifted.getTime() - SHANGHAI_OFFSET_MS)
}
/** 上海日历日 + n 个月后的零点 (月末溢出由 Date 归一化) */
function addMonths(dayStr, n) {
const [y, m, d] = dayStr.split('-').map(Number)
return new Date(Date.UTC(y, m - 1 + n, d) - SHANGHAI_OFFSET_MS)
}
/**
* 任务被隐藏的时刻: doneAt 所在完成日之后, 保留窗口结束的上海零点。
* 与 date.js isVisibleByRetention 的可见判定精确同口径:
* never → doneAt 即刻隐藏; today → 完成日次日零点; 3d → 完成日+3 零点;
* week → 完成周次下一周一零点; month → 次月 1 日零点; forever → 永不隐藏 (null)
*/
export function hiddenAtOf(doneAt, option) {
if (!doneAt) return null
const day = toDateStr(doneAt)
switch (option) {
case 'never': return new Date(doneAt)
case 'today': return shanghaiMidnight(addDays(day, 1))
case '3d': return shanghaiMidnight(addDays(day, 3))
case 'week': return shanghaiMidnight(addDays(startOfWeek(day), 7))
case 'month': return shanghaiMidnight(String(addDays(day, 32)).slice(0, 8) + '01')
default: return null // forever: 永不隐藏
}
}
/** 自动归档到期时刻: 隐藏时刻 + 档位; 'never' 或无法隐藏 → null */
export function autoArchiveDue(autoArchive, doneAt, columnRetention) {
if (!autoArchive || autoArchive === 'never') return null
const hidden = hiddenAtOf(doneAt, columnRetention)
if (!hidden) return null
/** 自动归档到期时刻: 完成时刻 + 档位; 'never' 或未完成 -> null */
export function autoArchiveDue(autoArchive, doneAt) {
if (!autoArchive || autoArchive === 'never' || !doneAt) return null
const at = doneAt instanceof Date ? doneAt : new Date(doneAt)
if (Number.isNaN(at.getTime())) return null
switch (autoArchive) {
case '3d': return new Date(hidden.getTime() + 3 * DAY_MS)
case '1w': return new Date(hidden.getTime() + 7 * DAY_MS)
case '1m': {
const d = new Date(hidden)
d.setUTCMonth(d.getUTCMonth() + 1)
return d
}
case '3d': return new Date(at.getTime() + 3 * DAY_MS)
case '1w': return new Date(at.getTime() + 7 * DAY_MS)
case '1m': return addMonthsShanghai(at, 1)
default: return null
}
}
/**
* 修复历史数据里父子归档状态不一致: 子任务完全跟随父任务。
* 只支持一级子任务; 返回被修正的条数。
*/
export async function normalizeArchiveTree(projectId) {
const tasks = await prisma.task.findMany({
where: { projectId },
select: { id: true, parentId: true, archived: true, archivedAt: true, archiveSource: true },
})
const byId = new Map(tasks.map((t) => [t.id, t]))
const fixes = []
for (const t of tasks) {
if (!t.parentId) continue
const parent = byId.get(t.parentId)
if (!parent || parent.archived === t.archived) continue
fixes.push({
id: t.id,
archived: parent.archived,
archivedAt: parent.archived ? (parent.archivedAt || new Date()) : null,
archiveSource: parent.archived ? (parent.archiveSource || 'manual') : null,
})
}
if (!fixes.length) return 0
await prisma.$transaction(fixes.map((f) => prisma.task.update({
where: { id: f.id },
data: {
archived: f.archived,
archivedAt: f.archivedAt,
archiveSource: f.archiveSource,
},
})))
return fixes.length
}
/**
* 对单个项目执行一轮到期自动归档 (T-ARC-02)。
* 只处理 status=done / doneAt 非空 / 未归档 的任务; 幂等。
* 只看顶层任务: 完成 + 档位到期 -> 该任务与全部子任务一起归档。幂等。
* @returns {number} 本次归档条数
*/
export async function runAutoArchiveForProject(projectId) {
// 先修复历史父子不一致, 保证要么都显示、要么都归档
await normalizeArchiveTree(projectId)
const project = await prisma.project.findUnique({
where: { id: projectId },
select: { autoArchive: true },
})
if (!project || !AUTO_ARCHIVE_OPTIONS.includes(project.autoArchive) || project.autoArchive === 'never') return 0
const columns = await prisma.boardColumn.findMany({
where: { projectId },
select: { id: true, retention: true },
})
const retentionById = new Map(columns.map((c) => [c.id, c.retention || 'forever']))
const tasks = await prisma.task.findMany({
where: { projectId, archived: false, status: 'done', doneAt: { not: null } },
select: { id: true, doneAt: true, columnId: true },
const roots = await prisma.task.findMany({
where: { projectId, parentId: null, archived: false, status: 'done', doneAt: { not: null } },
select: { id: true, doneAt: true },
})
const now = new Date()
const dueIds = []
for (const t of tasks) {
const due = autoArchiveDue(project.autoArchive, t.doneAt, retentionById.get(t.columnId))
if (due && due <= now) dueIds.push(t.id)
const dueRootIds = []
for (const t of roots) {
const due = autoArchiveDue(project.autoArchive, t.doneAt)
if (due && due <= now) dueRootIds.push(t.id)
}
if (!dueIds.length) return 0
if (!dueRootIds.length) return 0
// 整棵树一起归档: 子任务不单独判定, 避免父任务归档而子任务仍留在看板
const children = await prisma.task.findMany({
where: { parentId: { in: dueRootIds } },
select: { id: true },
})
const ids = [...dueRootIds, ...children.map((c) => c.id)]
const r = await prisma.task.updateMany({
where: { id: { in: dueIds } },
where: { id: { in: ids } },
data: { archived: true, archivedAt: now, archiveSource: 'auto' },
})
return r.count
}
/** 每小时 cron 兜底: 扫描所有启用了自动归档的项目 */
/** 每小时 cron 兜底: 修复所有项目的父子归档一致性, 并执行到期自动归档 */
export async function runAutoArchiveSweep() {
const projects = await prisma.project.findMany({
where: { autoArchive: { not: 'never' } },
select: { id: true },
})
const projects = await prisma.project.findMany({ select: { id: true } })
let total = 0
for (const p of projects) {
try {
-93
View File
@@ -1,93 +0,0 @@
// 任务模块: 自然日边界工具 (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
}
-31
View File
@@ -1,31 +0,0 @@
// 任务模块: 保留选项的可见性过滤 (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
}