- 数据: Project.autoArchive + Task.archived/archivedAt/archiveSource, 迁移 20261026000000_task_archive (两索引) - 领域: server/task-domain/archive.js —— hiddenAtOf 与保留口径精确同源, autoArchiveDue 到期判定, runAutoArchiveForProject (读时惰性) + runAutoArchiveSweep (每小时 cron 兜底) - 接口: POST /tasks/:id/archive|unarchive (连带子任务, 幂等); GET /projects/:id/tasks/archived (keyword/kind/columnId/from/to 过滤, archivedAt+id 游标分页); 项目 PUT 收 autoArchive (枚举校验); 主列表/子任务/项目 taskCount 全部排除归档 - 前端: ⋯ 菜单「归档」(确认含子任务条数); 项目栏「已归档」入口 → ArchivedTasks 弹层 (关键词/类型/列/归档时段过滤 + 加载更多 + 恢复/删除); 项目管理「归档设置」四档 Picker
This commit is contained in:
@@ -41,6 +41,9 @@ export async function ensurePersonalProject(userId) {
|
||||
// 任务类型标签: 项目级自定义, 未配置时用默认四类
|
||||
export const DEFAULT_TASK_KINDS = ['需求', 'Bug', 'FEATURE', '优化']
|
||||
|
||||
// 自动归档档位 (T-ARC-02)
|
||||
export const AUTO_ARCHIVE_OPTIONS = ['3d', '1w', '1m', 'never']
|
||||
|
||||
export function normTaskKinds(v) {
|
||||
if (!Array.isArray(v)) return null
|
||||
const out = []
|
||||
@@ -63,6 +66,7 @@ function projectSummary(p) {
|
||||
updatedAt: p.updatedAt,
|
||||
taskKinds: normTaskKinds(p.taskKinds) || DEFAULT_TASK_KINDS,
|
||||
icon: p.icon || null,
|
||||
autoArchive: AUTO_ARCHIVE_OPTIONS.includes(p.autoArchive) ? p.autoArchive : 'never',
|
||||
memberCount: p._count?.members ?? undefined,
|
||||
taskCount: p._count?.tasks ?? undefined,
|
||||
}
|
||||
@@ -77,7 +81,7 @@ router.get('/', async (req, res, next) => {
|
||||
await ensurePersonalProject(userId)
|
||||
const rows = await prisma.project.findMany({
|
||||
where: { members: { some: { userId } } },
|
||||
include: { _count: { select: { members: true, tasks: true } } },
|
||||
include: { _count: { select: { members: true, tasks: { where: { archived: false } } } } },
|
||||
orderBy: [{ archived: 'asc' }, { updatedAt: 'desc' }],
|
||||
})
|
||||
res.json({ items: rows.map(projectSummary) })
|
||||
@@ -98,7 +102,7 @@ router.post('/', async (req, res, next) => {
|
||||
await createDefaultColumns(project.id)
|
||||
const full = await prisma.project.findUnique({
|
||||
where: { id: project.id },
|
||||
include: { _count: { select: { members: true, tasks: true } } },
|
||||
include: { _count: { select: { members: true, tasks: { where: { archived: false } } } } },
|
||||
})
|
||||
res.json({ item: projectSummary(full) })
|
||||
} catch (e) { next(e) }
|
||||
@@ -135,7 +139,7 @@ router.put('/:id', async (req, res, next) => {
|
||||
const userId = req.user.sub
|
||||
const project = await loadProjectForUser(req.params.id, userId)
|
||||
if (!project) return res.status(404).json({ error: '项目不存在或无权限' })
|
||||
const { name, archived, notes, taskKinds, icon } = req.body || {}
|
||||
const { name, archived, notes, taskKinds, icon, autoArchive } = req.body || {}
|
||||
const data = {}
|
||||
if (name !== undefined) {
|
||||
const t = String(name).trim()
|
||||
@@ -159,6 +163,11 @@ router.put('/:id', async (req, res, next) => {
|
||||
} else if (icon === null) {
|
||||
data.icon = null
|
||||
}
|
||||
// 自动归档档位 (T-ARC-02)
|
||||
if (autoArchive !== undefined) {
|
||||
if (!AUTO_ARCHIVE_OPTIONS.includes(autoArchive)) return res.status(400).json({ error: '自动归档档位非法' })
|
||||
data.autoArchive = autoArchive
|
||||
}
|
||||
if (archived !== undefined) data.archived = !!archived
|
||||
// 项目信息块 (T-PJ-13): 任意多个 { title, content }, 整体替换
|
||||
if (notes !== undefined) {
|
||||
|
||||
+84
-1
@@ -8,10 +8,13 @@ import {
|
||||
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'
|
||||
|
||||
const router = Router()
|
||||
router.use(authRequired)
|
||||
|
||||
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/
|
||||
|
||||
const TASK_INCLUDE = {
|
||||
column: true,
|
||||
assignee: { select: { id: true, name: true } },
|
||||
@@ -19,6 +22,7 @@ const TASK_INCLUDE = {
|
||||
members: { include: { user: { select: { id: true, name: true } } } },
|
||||
tags: true,
|
||||
children: {
|
||||
where: { archived: false },
|
||||
include: {
|
||||
column: true,
|
||||
assignee: { select: { id: true, name: true } },
|
||||
@@ -50,9 +54,12 @@ router.get('/projects/:id/tasks', async (req, res, next) => {
|
||||
const project = await loadProjectForUser(req.params.id, userId)
|
||||
if (!project) return res.status(404).json({ error: '项目不存在或无权限' })
|
||||
|
||||
// 读时惰性自动归档 (T-ARC-02): 保证刚到期的隐藏任务当场消失
|
||||
await runAutoArchiveForProject(project.id)
|
||||
|
||||
const columns = await loadColumns(project.id)
|
||||
const rows = await prisma.task.findMany({
|
||||
where: { projectId: project.id, parentId: null },
|
||||
where: { projectId: project.id, parentId: null, archived: false },
|
||||
include: TASK_INCLUDE,
|
||||
orderBy: [{ sortOrder: 'asc' }, { createdAt: 'asc' }],
|
||||
})
|
||||
@@ -63,6 +70,82 @@ router.get('/projects/:id/tasks', async (req, res, next) => {
|
||||
} catch (e) { next(e) }
|
||||
})
|
||||
|
||||
// ---------- 归档 (T-ARC) ----------
|
||||
|
||||
// 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 r = await prisma.task.updateMany({
|
||||
where: { id: { in: [task.id, ...children.map((c) => c.id)] } },
|
||||
data: { archived: true, archivedAt: new Date(), archiveSource: 'manual' },
|
||||
})
|
||||
res.json({ ok: true, count: r.count })
|
||||
} catch (e) { next(e) }
|
||||
})
|
||||
|
||||
// POST /api/tasks/:id/unarchive 恢复: 回原列, 状态不变, 连带子任务
|
||||
router.post('/tasks/:id/unarchive', 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 r = await prisma.task.updateMany({
|
||||
where: { id: { in: [task.id, ...children.map((c) => c.id)] } },
|
||||
data: { archived: false, archivedAt: null, archiveSource: null },
|
||||
})
|
||||
res.json({ ok: true, count: r.count })
|
||||
} catch (e) { next(e) }
|
||||
})
|
||||
|
||||
// GET /api/projects/:id/tasks/archived 已归档查询: 过滤 + 游标分页 (T-ARC-04)
|
||||
router.get('/projects/:id/tasks/archived', 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 q = req.query || {}
|
||||
const where = { projectId: project.id, archived: true, parentId: null }
|
||||
if (q.keyword) where.title = { contains: String(q.keyword).trim() }
|
||||
if (q.kind) where.kind = String(q.kind)
|
||||
if (q.columnId && Number.isFinite(Number(q.columnId))) where.columnId = Number(q.columnId)
|
||||
if (q.from && DATE_RE.test(q.from)) where.archivedAt = { ...(where.archivedAt || {}), gte: new Date(q.from + 'T00:00:00+08:00') }
|
||||
if (q.to && DATE_RE.test(q.to)) where.archivedAt = { ...(where.archivedAt || {}), lte: new Date(q.to + 'T23:59:59.999+08:00') }
|
||||
|
||||
const limit = Math.min(100, Math.max(1, Number(q.limit) || 20))
|
||||
if (q.cursor) {
|
||||
const [ts, id] = String(q.cursor).split('|')
|
||||
const t = new Date(ts)
|
||||
if (Number.isNaN(t.getTime()) || !Number.isFinite(Number(id))) {
|
||||
return res.status(400).json({ error: 'cursor 无效' })
|
||||
}
|
||||
where.OR = [
|
||||
{ archivedAt: { lt: t } },
|
||||
{ archivedAt: t, id: { lt: Number(id) } },
|
||||
]
|
||||
}
|
||||
|
||||
const rows = await prisma.task.findMany({
|
||||
where,
|
||||
include: TASK_INCLUDE,
|
||||
orderBy: [{ archivedAt: 'desc' }, { id: 'desc' }],
|
||||
take: limit + 1,
|
||||
})
|
||||
const hasMore = rows.length > limit
|
||||
const page = rows.slice(0, limit)
|
||||
const last = page[page.length - 1]
|
||||
const nextCursor = hasMore && last ? `${last.archivedAt.toISOString()}|${last.id}` : null
|
||||
res.json({ items: page.map(shape), nextCursor })
|
||||
} 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 {
|
||||
|
||||
Reference in New Issue
Block a user