diff --git a/prisma/migrations/20261026000000_task_archive/migration.sql b/prisma/migrations/20261026000000_task_archive/migration.sql
new file mode 100644
index 0000000..fc8d9f4
--- /dev/null
+++ b/prisma/migrations/20261026000000_task_archive/migration.sql
@@ -0,0 +1,7 @@
+-- 任务归档 (T-ARC): 项目自动归档档位 + 任务归档三字段与查询索引
+ALTER TABLE `Project` ADD COLUMN `autoArchive` VARCHAR(8) NOT NULL DEFAULT 'never';
+ALTER TABLE `Task` ADD COLUMN `archived` BOOLEAN NOT NULL DEFAULT false;
+ALTER TABLE `Task` ADD COLUMN `archivedAt` DATETIME(3) NULL;
+ALTER TABLE `Task` ADD COLUMN `archiveSource` VARCHAR(8) NULL;
+ALTER TABLE `Task` ADD INDEX `idx_task_project_archived` (`projectId`, `archived`, `archivedAt`);
+ALTER TABLE `Task` ADD INDEX `idx_task_project_status_arch` (`projectId`, `status`, `archived`);
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 5a0fe01..420f6e0 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -396,6 +396,7 @@ model Project {
notes Json @default("[]") // 项目信息块 [{ title, content }] (T-PJ-13)
taskKinds Json? // 任务类型标签列表 (未配置时服务端回落默认四类)
icon String? @db.Text // 项目图标 (data URL, 客户端裁剪为 96x96 JPEG)
+ autoArchive String @default("never") @db.VarChar(8) // 自动归档档位: 3d/1w/1m/never (T-ARC-02)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -461,6 +462,9 @@ model Task {
extras Json @default("[]")
sortOrder Int @default(0)
+ archived Boolean @default(false)
+ archivedAt DateTime?
+ archiveSource String? @db.VarChar(8) // manual | auto (T-ARC)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -471,6 +475,8 @@ model Task {
@@index([projectId, status])
@@index([parentId])
@@index([projectId, doneAt])
+ @@index([projectId, archived, archivedAt])
+ @@index([projectId, status, archived])
}
model TaskMember {
diff --git a/server/cron.js b/server/cron.js
index c8f9a62..a5fa86c 100644
--- a/server/cron.js
+++ b/server/cron.js
@@ -1,94 +1,110 @@
-import cron from 'node-cron'
-import { prisma } from './db.js'
-import mysql from 'mysql2/promise'
-import { checkAfterRefresh } from './alert-engine.js'
-import { ApizeroError, oil, history } from './apizero/index.js'
-
-// scripts/ 下的可选脚本模块 (部署容器可能缺失或依赖不全): 惰性加载,
-// 单个 fetcher 缺失只降级对应任务, 不拖垮 cron 整体注册。
-async function importNewsFetcher() {
- const mod = await import('../scripts/fetchers/news.fetcher.js')
- return { fetchNewsList: mod.fetchNewsList, persistNews: mod.persistNews }
-}
-
-async function alertError(taskName, message) {
- try {
- const { insertCronAlert } = await import('../scripts/lib/cron-alert.js')
- await insertCronAlert(taskName, message)
- } catch (e) {
- console.error('[cron] 报警写入失败(忽略):', e.message)
- }
-}
-
-function parseUrl(url) {
- const u = new URL(url)
- return { host: u.hostname, port: Number(u.port) || 3306, user: decodeURIComponent(u.username), password: decodeURIComponent(u.password), database: u.pathname.replace(/^\//, '') }
-}
-
-async function openConn() {
- return mysql.createConnection(parseUrl(process.env.DATABASE_URL))
-}
-
-// 每天 6:00 (Asia/Shanghai) 拉取新闻 + 跑跨源对比, 出错生成报警
-function scheduleNews() {
- cron.schedule('0 6 * * *', async () => {
- console.log('[cron] 6:00 触发')
- try {
- const { fetchNewsList, persistNews } = await importNewsFetcher()
- const list = await fetchNewsList()
- const r = await persistNews(list)
- console.log('[cron] 新闻入库: 新增 ' + r.inserted + ' / 跳过 ' + r.skipped)
- const conn = await openConn()
- try {
- const a = await checkAfterRefresh(conn)
- console.log('[cron] 报警检测: 新增 ' + a.inserted + ' 条 (扫描 ' + a.scanned + ')')
- } finally { await conn.end() }
- } catch (e) {
- console.error('[cron] 任务失败:', e.message)
- await alertError('新闻拉取+报警检测', e.message)
- }
- }, { timezone: 'Asia/Shanghai' })
- console.log('[cron] 已注册: 每天 06:00 拉取新闻 + 跨源报警检测')
-}
-
-// 每天 6:10 (Asia/Shanghai) 抓浙江油价历史, 出错生成报警
-// 逻辑: 拉今年 schedule, 检查待定且 <= 昨天的调价日, 命中就抓 price?province=浙江 存库
-// - 0 命中: 不调 apizero (省配额)
-// - >0 命中: 调一次, 4 个油品批量入库 (unique 兜底去重)
-function scheduleOilHistory() {
- cron.schedule('10 6 * * *', async () => {
- console.log('[cron] 6:10 油价历史触发')
- try {
- const year = new Date().getFullYear()
- const sched = await oil.getSchedule(year)
- const list = sched.schedule || []
- // 待定 且 日期 <= 昨天 (用 Asia/Shanghai 时区推导, 与 cron 触发时区对齐)
- const todayStr = new Date().toLocaleDateString('en-CA', { timeZone: 'Asia/Shanghai' })
- const today = new Date(todayStr + 'T00:00:00+08:00')
- const yesterdayMs = today.getTime() - 86400000
- const pendingPast = list.filter((it) => {
- if (it.status !== '待定') return false
- const t = new Date(it.date + 'T00:00:00').getTime()
- return Number.isFinite(t) && t <= yesterdayMs
- })
- console.log('[cron] 待定且已过的调价日: ' + pendingPast.length + ' 个')
- if (pendingPast.length === 0) {
- console.log('[cron] 油价历史: 0 命中, 跳过 apizero 调用')
- return
- }
- const r = await history.fetchAndStore()
- console.log('[cron] 油价历史入库: 新增 ' + r.inserted + ' / 跳过 ' + r.skipped + ' (date=' + r.date + ')')
- } catch (e) {
- if (e instanceof ApizeroError) {
- console.error('[cron] 油价历史 apizero 错误: code=' + e.code + ' msg=' + e.message)
- } else {
- console.error('[cron] 油价历史任务失败:', e.message)
- }
- await alertError('浙江油价历史抓取', e.message)
- }
- }, { timezone: 'Asia/Shanghai' })
- console.log('[cron] 已注册: 每天 06:10 浙江油价历史抓取')
-}
-
-scheduleNews()
-scheduleOilHistory()
+import cron from 'node-cron'
+import { prisma } from './db.js'
+import mysql from 'mysql2/promise'
+import { checkAfterRefresh } from './alert-engine.js'
+import { ApizeroError, oil, history } from './apizero/index.js'
+
+// scripts/ 下的可选脚本模块 (部署容器可能缺失或依赖不全): 惰性加载,
+// 单个 fetcher 缺失只降级对应任务, 不拖垮 cron 整体注册。
+async function importNewsFetcher() {
+ const mod = await import('../scripts/fetchers/news.fetcher.js')
+ return { fetchNewsList: mod.fetchNewsList, persistNews: mod.persistNews }
+}
+
+async function alertError(taskName, message) {
+ try {
+ const { insertCronAlert } = await import('../scripts/lib/cron-alert.js')
+ await insertCronAlert(taskName, message)
+ } catch (e) {
+ console.error('[cron] 报警写入失败(忽略):', e.message)
+ }
+}
+
+function parseUrl(url) {
+ const u = new URL(url)
+ return { host: u.hostname, port: Number(u.port) || 3306, user: decodeURIComponent(u.username), password: decodeURIComponent(u.password), database: u.pathname.replace(/^\//, '') }
+}
+
+async function openConn() {
+ return mysql.createConnection(parseUrl(process.env.DATABASE_URL))
+}
+
+// 每天 6:00 (Asia/Shanghai) 拉取新闻 + 跑跨源对比, 出错生成报警
+function scheduleNews() {
+ cron.schedule('0 6 * * *', async () => {
+ console.log('[cron] 6:00 触发')
+ try {
+ const { fetchNewsList, persistNews } = await importNewsFetcher()
+ const list = await fetchNewsList()
+ const r = await persistNews(list)
+ console.log('[cron] 新闻入库: 新增 ' + r.inserted + ' / 跳过 ' + r.skipped)
+ const conn = await openConn()
+ try {
+ const a = await checkAfterRefresh(conn)
+ console.log('[cron] 报警检测: 新增 ' + a.inserted + ' 条 (扫描 ' + a.scanned + ')')
+ } finally { await conn.end() }
+ } catch (e) {
+ console.error('[cron] 任务失败:', e.message)
+ await alertError('新闻拉取+报警检测', e.message)
+ }
+ }, { timezone: 'Asia/Shanghai' })
+ console.log('[cron] 已注册: 每天 06:00 拉取新闻 + 跨源报警检测')
+}
+
+// 每天 6:10 (Asia/Shanghai) 抓浙江油价历史, 出错生成报警
+// 逻辑: 拉今年 schedule, 检查待定且 <= 昨天的调价日, 命中就抓 price?province=浙江 存库
+// - 0 命中: 不调 apizero (省配额)
+// - >0 命中: 调一次, 4 个油品批量入库 (unique 兜底去重)
+function scheduleOilHistory() {
+ cron.schedule('10 6 * * *', async () => {
+ console.log('[cron] 6:10 油价历史触发')
+ try {
+ const year = new Date().getFullYear()
+ const sched = await oil.getSchedule(year)
+ const list = sched.schedule || []
+ // 待定 且 日期 <= 昨天 (用 Asia/Shanghai 时区推导, 与 cron 触发时区对齐)
+ const todayStr = new Date().toLocaleDateString('en-CA', { timeZone: 'Asia/Shanghai' })
+ const today = new Date(todayStr + 'T00:00:00+08:00')
+ const yesterdayMs = today.getTime() - 86400000
+ const pendingPast = list.filter((it) => {
+ if (it.status !== '待定') return false
+ const t = new Date(it.date + 'T00:00:00').getTime()
+ return Number.isFinite(t) && t <= yesterdayMs
+ })
+ console.log('[cron] 待定且已过的调价日: ' + pendingPast.length + ' 个')
+ if (pendingPast.length === 0) {
+ console.log('[cron] 油价历史: 0 命中, 跳过 apizero 调用')
+ return
+ }
+ const r = await history.fetchAndStore()
+ console.log('[cron] 油价历史入库: 新增 ' + r.inserted + ' / 跳过 ' + r.skipped + ' (date=' + r.date + ')')
+ } catch (e) {
+ if (e instanceof ApizeroError) {
+ console.error('[cron] 油价历史 apizero 错误: code=' + e.code + ' msg=' + e.message)
+ } else {
+ console.error('[cron] 油价历史任务失败:', e.message)
+ }
+ await alertError('浙江油价历史抓取', e.message)
+ }
+ }, { timezone: 'Asia/Shanghai' })
+ console.log('[cron] 已注册: 每天 06:10 浙江油价历史抓取')
+}
+
+// 每小时兜底: 任务自动归档扫描 (T-ARC-02; 项目加载时另有读时惰性执行)
+function scheduleAutoArchive() {
+ cron.schedule('30 * * * *', async () => {
+ try {
+ const { runAutoArchiveSweep } = await import('./task-domain/archive.js')
+ const n = await runAutoArchiveSweep()
+ if (n > 0) console.log('[cron] 自动归档:', n, '条')
+ } catch (e) {
+ console.error('[cron] 自动归档失败:', e.message)
+ await alertError('任务自动归档', e.message)
+ }
+ }, { timezone: 'Asia/Shanghai' })
+ console.log('[cron] 已注册: 每小时 30 分 任务自动归档扫描')
+}
+
+scheduleNews()
+scheduleOilHistory()
+scheduleAutoArchive()
diff --git a/server/routes/projects.js b/server/routes/projects.js
index 6eecd6f..3aba959 100644
--- a/server/routes/projects.js
+++ b/server/routes/projects.js
@@ -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) {
diff --git a/server/routes/tasks.js b/server/routes/tasks.js
index b62f235..ccfa442 100644
--- a/server/routes/tasks.js
+++ b/server/routes/tasks.js
@@ -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 {
diff --git a/server/task-domain/archive.js b/server/task-domain/archive.js
new file mode 100644
index 0000000..aa84760
--- /dev/null
+++ b/server/task-domain/archive.js
@@ -0,0 +1,112 @@
+// 任务归档领域逻辑 (T-ARC)
+// 自动归档判定: 倒计时起点 = 任务"被隐藏的时刻"(与 date.js isVisibleByRetention
+// 精确同口径推导): 完成日 + 保留窗口后的上海零点; 加上项目 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 个月后的零点 (月末溢出由 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
+ 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
+ }
+ default: return null
+ }
+}
+
+/**
+ * 对单个项目执行一轮到期自动归档 (T-ARC-02)。
+ * 只处理 status=done / doneAt 非空 / 未归档 的任务; 幂等。
+ * @returns {number} 本次归档条数
+ */
+export async function runAutoArchiveForProject(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 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)
+ }
+ if (!dueIds.length) return 0
+
+ const r = await prisma.task.updateMany({
+ where: { id: { in: dueIds } },
+ data: { archived: true, archivedAt: now, archiveSource: 'auto' },
+ })
+ return r.count
+}
+
+/** 每小时 cron 兜底: 扫描所有启用了自动归档的项目 */
+export async function runAutoArchiveSweep() {
+ const projects = await prisma.project.findMany({
+ where: { autoArchive: { not: 'never' } },
+ select: { id: true },
+ })
+ let total = 0
+ for (const p of projects) {
+ try {
+ total += await runAutoArchiveForProject(p.id)
+ } catch (e) {
+ console.error('[archive] 项目自动归档失败:', p.id, e.message)
+ }
+ }
+ return total
+}
diff --git a/src/api/task.js b/src/api/task.js
index 1c88765..660bb0a 100644
--- a/src/api/task.js
+++ b/src/api/task.js
@@ -38,4 +38,7 @@ export const taskApi = {
reorder: (projectId, ids) => api(`/projects/${projectId}/tasks/reorder`, { method: 'POST', body: { ids } }),
remove: (id) => api(`/tasks/${id}`, { method: 'DELETE' }),
copy: (id, projectId) => api(`/tasks/${id}/copy`, { method: 'POST', body: { projectId } }),
+ archive: (id) => api(`/tasks/${id}/archive`, { method: 'POST', body: {} }),
+ unarchive: (id) => api(`/tasks/${id}/unarchive`, { method: 'POST', body: {} }),
+ archivedList: (projectId, params) => api(`/projects/${projectId}/tasks/archived` + q(params)),
}
diff --git a/src/pages/task/ArchivedTasks.jsx b/src/pages/task/ArchivedTasks.jsx
new file mode 100644
index 0000000..64fbe4b
--- /dev/null
+++ b/src/pages/task/ArchivedTasks.jsx
@@ -0,0 +1,208 @@
+import { useCallback, useEffect, useRef, useState } from 'react'
+import { Button, DatePicker, Dialog, DotLoading, Input, Popup, Selector, Toast } from 'antd-mobile'
+import { taskApi } from '../../api/task.js'
+import { kindColor } from './kind.js'
+
+// 已归档任务查询 (T-ARC-04/05/06)
+// 过滤: 关键词(标题) / 任务类型 / 看板列 / 归档时间范围
+// 游标分页: 「加载更多」追加; 行内 恢复 / 彻底删除
+const LIMIT = 20
+
+export default function ArchivedTasks({ project, visible, onClose, onChanged }) {
+ const [items, setItems] = useState(null)
+ const [nextCursor, setNextCursor] = useState(null)
+ const [loading, setLoading] = useState(false)
+ const [filters, setFilters] = useState({ keyword: '', kind: '', columnId: '', from: '', to: '' })
+ const [fromVisible, setFromVisible] = useState(false)
+ const [toVisible, setToVisible] = useState(false)
+ const cursorRef = useRef('')
+
+ const columns = project?.columns || []
+ const kinds = project?.taskKinds || []
+
+ const buildParams = (f, cursor) => {
+ const p = { limit: LIMIT }
+ if (f.keyword.trim()) p.keyword = f.keyword.trim()
+ if (f.kind) p.kind = f.kind
+ if (f.columnId) p.columnId = f.columnId
+ if (f.from) p.from = f.from
+ if (f.to) p.to = f.to
+ if (cursor) p.cursor = cursor
+ return p
+ }
+
+ const load = useCallback(async (reset) => {
+ setLoading(true)
+ try {
+ const cursor = reset ? '' : cursorRef.current
+ const r = await taskApi.archivedList(project.id, buildParams(filters, cursor))
+ setItems((prev) => (reset || !prev ? r.items : [...prev, ...r.items]))
+ cursorRef.current = r.nextCursor || ''
+ setNextCursor(r.nextCursor || null)
+ } catch (e) {
+ Toast.show({ content: e.message || '加载失败' })
+ } finally {
+ setLoading(false)
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [project.id, filters])
+
+ useEffect(() => {
+ if (!visible) return
+ setItems(null)
+ cursorRef.current = ''
+ load(true)
+ }, [visible, load])
+
+ const setF = (patch) => setFilters((p) => ({ ...p, ...patch }))
+
+ async function doRestore(item) {
+ try {
+ await taskApi.unarchive(item.id)
+ Toast.show({ content: '已恢复' })
+ setItems((prev) => (prev || []).filter((x) => x.id !== item.id))
+ onChanged?.()
+ load(true)
+ } catch (e) {
+ Toast.show({ content: e.message || '恢复失败' })
+ }
+ }
+
+ async function doRemove(item) {
+ const subs = item.children?.length || 0
+ const ok = await Dialog.confirm({
+ content: subs ? `彻底删除「${item.title}」?将一并删除 ${subs} 条子任务,不可恢复。` : `彻底删除「${item.title}」?不可恢复。`,
+ confirmText: '删除',
+ })
+ if (!ok) return
+ try {
+ await taskApi.remove(item.id)
+ Toast.show({ content: '已删除' })
+ setItems((prev) => (prev || []).filter((x) => x.id !== item.id))
+ onChanged?.()
+ } catch (e) {
+ Toast.show({ content: e.message || '删除失败' })
+ }
+ }
+
+ const fmt = (iso) => (iso ? iso.slice(0, 10) : '')
+ const colName = (id) => columns.find((c) => c.id === id)?.name || ''
+
+ return (
+