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 ( + +
+
+ ‹ + 已归档任务 +
+ +
+ load(true)} + onChange={(v) => setF({ keyword: v })} + onBlur={() => load(true)} + /> +
+
+ ({ label: k, value: k }))]} + value={[filters.kind]} + onSelect={(_, ext) => { setF({ kind: ext.items[0].value }); setTimeout(() => load(true), 0) }} + /> +
+
+ ({ label: c.name, value: String(c.id) }))]} + value={[filters.columnId]} + onSelect={(_, ext) => { setF({ columnId: ext.items[0].value }); setTimeout(() => load(true), 0) }} + /> +
+ 归档 + setFromVisible(true)}> + {filters.from || '开始日期'} + + ~ + setToVisible(true)}> + {filters.to || '结束日期'} + + {(filters.from || filters.to) && ( + { setF({ from: '', to: '' }); setTimeout(() => load(true), 0) }}>清除 + )} +
+
+ + setFromVisible(false)} + precision="day" + onConfirm={(d) => { + const p = (n) => String(n).padStart(2, '0') + setF({ from: `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}` }) + setFromVisible(false) + setTimeout(() => load(true), 0) + }} + /> + setToVisible(false)} + precision="day" + onConfirm={(d) => { + const p = (n) => String(n).padStart(2, '0') + setF({ to: `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}` }) + setToVisible(false) + setTimeout(() => load(true), 0) + }} + /> + +
+ {items === null && loading &&
加载中...
} + {items !== null && items.length === 0 && !loading && ( +
没有符合条件的归档任务
+ )} + {(items || []).map((t) => ( +
+
+
{t.title}
+
+ {t.kind && {t.kind}} + {colName(t.columnId)} + 归档于 {fmt(t.archivedAt)} +
+
+
+ + +
+
+ ))} + {nextCursor && ( + + )} +
+
+
+ ) +} diff --git a/src/pages/task/ProjectManage.jsx b/src/pages/task/ProjectManage.jsx index 6cc4ed3..aaa2ba3 100644 --- a/src/pages/task/ProjectManage.jsx +++ b/src/pages/task/ProjectManage.jsx @@ -1,5 +1,5 @@ import { useRef, useState } from 'react' -import { Dialog, Input, Popup, Toast } from 'antd-mobile' +import { Dialog, Input, Picker, Popup, Toast } from 'antd-mobile' import { projectApi } from '../../api/task.js' import { useAuth } from '../../auth/AuthContext.jsx' import useKeyboardInset from '../../hooks/useKeyboardInset.js' @@ -24,6 +24,15 @@ export default function ProjectManage({ project, onClose, onChanged, onExited }) const [newKind, setNewKind] = useState('') // 项目图标更换: 隐藏 file input const iconInputRef = useRef(null) + // 自动归档档位 (T-ARC-02) + const AUTO_ARCHIVE = [ + { label: '3 天后', value: '3d' }, + { label: '一周后', value: '1w' }, + { label: '一月后', value: '1m' }, + { label: '永不', value: 'never' }, + ] + const AUTO_ARCHIVE_LABEL = Object.fromEntries(AUTO_ARCHIVE.map((o) => [o.value, o.label])) + const [archivePick, setArchivePick] = useState(false) const members = project.members || [] const notes = Array.isArray(project.notes) ? project.notes : [] @@ -238,6 +247,14 @@ export default function ProjectManage({ project, onClose, onChanged, onExited }) 列设置 › +
归档设置
+
setArchivePick(true)}> + 隐藏的任务自动归档 + + {AUTO_ARCHIVE_LABEL[project.autoArchive] || '永不'} › + +
+
任务类型
{ setKinds(project.taskKinds || DEFAULT_KINDS); setNewKind(''); setKindEdit(true) }}>
@@ -328,6 +345,24 @@ export default function ProjectManage({ project, onClose, onChanged, onExited }) onClose={() => setRenaming(false)} /> + {/* 自动归档档位选择 (T-ARC-02) */} + setArchivePick(false)} + columns={[AUTO_ARCHIVE]} + value={[project.autoArchive || 'never']} + onConfirm={async (v) => { + setArchivePick(false) + try { + await projectApi.update(project.id, { autoArchive: v[0] }) + Toast.show({ content: '已保存' }) + onChanged?.() + } catch (e) { + Toast.show({ content: e.message }) + } + }} + /> + {/* 任务类型编辑: chips 删除 + 输入新增 */} { setErr('') @@ -169,6 +171,7 @@ export default function ProjectView({ projectId, onProjectChanged, onProjectExit const handler = ActionSheet.show({ actions: [ { text: '编辑', key: 'edit' }, + { text: '归档', key: 'archive' }, { text: '拷贝到其他项目', key: 'copy' }, { text: '删除', key: 'delete', bold: true, danger: true }, ], @@ -176,6 +179,21 @@ export default function ProjectView({ projectId, onProjectChanged, onProjectExit onAction: async (item) => { handler.close?.() if (item.key === 'edit') setOpenTask(task) + else if (item.key === 'archive') { + const subs = task.children?.length || 0 + const ok = await Dialog.confirm({ + content: subs ? `归档「${task.title}」?将连带归档 ${subs} 条子任务。` : `归档「${task.title}」?`, + confirmText: '归档', + }) + if (!ok) return + try { + await taskApi.archive(task.id) + Toast.show({ content: '已归档' }) + load() + } catch (e) { + Toast.show({ content: e.message }) + } + } else if (item.key === 'copy') setCopyTask(task) else if (item.key === 'delete') { const kids = task.children?.length || 0 @@ -225,6 +243,7 @@ export default function ProjectView({ projectId, onProjectChanged, onProjectExit
{project.name}
+ setArchivedOpen(true)}>已归档 setShowManage(true)}>项目管理 setAdding((v) => !v)}>+ 新增
@@ -301,6 +320,15 @@ export default function ProjectView({ projectId, onProjectChanged, onProjectExit /> )} + {archivedOpen && ( + setArchivedOpen(false)} + onChanged={load} + /> + )} + {showManage && (