feat(任务模块): 任务归档 T-ARC-01~08 实现
Docker Build and Push / build-image (push) Successful in 1m27s

- 数据: 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:
jif2.zhang
2026-10-25 23:09:52 +08:00
parent 39282b9633
commit 368e99a380
10 changed files with 606 additions and 99 deletions
@@ -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`);
+6
View File
@@ -396,6 +396,7 @@ model Project {
notes Json @default("[]") // 项目信息块 [{ title, content }] (T-PJ-13) notes Json @default("[]") // 项目信息块 [{ title, content }] (T-PJ-13)
taskKinds Json? // 任务类型标签列表 (未配置时服务端回落默认四类) taskKinds Json? // 任务类型标签列表 (未配置时服务端回落默认四类)
icon String? @db.Text // 项目图标 (data URL, 客户端裁剪为 96x96 JPEG) 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()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@ -461,6 +462,9 @@ model Task {
extras Json @default("[]") extras Json @default("[]")
sortOrder Int @default(0) sortOrder Int @default(0)
archived Boolean @default(false)
archivedAt DateTime?
archiveSource String? @db.VarChar(8) // manual | auto (T-ARC)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@ -471,6 +475,8 @@ model Task {
@@index([projectId, status]) @@index([projectId, status])
@@index([parentId]) @@index([parentId])
@@index([projectId, doneAt]) @@index([projectId, doneAt])
@@index([projectId, archived, archivedAt])
@@index([projectId, status, archived])
} }
model TaskMember { model TaskMember {
+16
View File
@@ -90,5 +90,21 @@ function scheduleOilHistory() {
console.log('[cron] 已注册: 每天 06:10 浙江油价历史抓取') 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() scheduleNews()
scheduleOilHistory() scheduleOilHistory()
scheduleAutoArchive()
+12 -3
View File
@@ -41,6 +41,9 @@ export async function ensurePersonalProject(userId) {
// 任务类型标签: 项目级自定义, 未配置时用默认四类 // 任务类型标签: 项目级自定义, 未配置时用默认四类
export const DEFAULT_TASK_KINDS = ['需求', 'Bug', 'FEATURE', '优化'] export const DEFAULT_TASK_KINDS = ['需求', 'Bug', 'FEATURE', '优化']
// 自动归档档位 (T-ARC-02)
export const AUTO_ARCHIVE_OPTIONS = ['3d', '1w', '1m', 'never']
export function normTaskKinds(v) { export function normTaskKinds(v) {
if (!Array.isArray(v)) return null if (!Array.isArray(v)) return null
const out = [] const out = []
@@ -63,6 +66,7 @@ function projectSummary(p) {
updatedAt: p.updatedAt, updatedAt: p.updatedAt,
taskKinds: normTaskKinds(p.taskKinds) || DEFAULT_TASK_KINDS, taskKinds: normTaskKinds(p.taskKinds) || DEFAULT_TASK_KINDS,
icon: p.icon || null, icon: p.icon || null,
autoArchive: AUTO_ARCHIVE_OPTIONS.includes(p.autoArchive) ? p.autoArchive : 'never',
memberCount: p._count?.members ?? undefined, memberCount: p._count?.members ?? undefined,
taskCount: p._count?.tasks ?? undefined, taskCount: p._count?.tasks ?? undefined,
} }
@@ -77,7 +81,7 @@ router.get('/', async (req, res, next) => {
await ensurePersonalProject(userId) await ensurePersonalProject(userId)
const rows = await prisma.project.findMany({ const rows = await prisma.project.findMany({
where: { members: { some: { userId } } }, 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' }], orderBy: [{ archived: 'asc' }, { updatedAt: 'desc' }],
}) })
res.json({ items: rows.map(projectSummary) }) res.json({ items: rows.map(projectSummary) })
@@ -98,7 +102,7 @@ router.post('/', async (req, res, next) => {
await createDefaultColumns(project.id) await createDefaultColumns(project.id)
const full = await prisma.project.findUnique({ const full = await prisma.project.findUnique({
where: { id: project.id }, 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) }) res.json({ item: projectSummary(full) })
} catch (e) { next(e) } } catch (e) { next(e) }
@@ -135,7 +139,7 @@ router.put('/:id', async (req, res, next) => {
const userId = req.user.sub const userId = req.user.sub
const project = await loadProjectForUser(req.params.id, userId) const project = await loadProjectForUser(req.params.id, userId)
if (!project) return res.status(404).json({ error: '项目不存在或无权限' }) 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 = {} const data = {}
if (name !== undefined) { if (name !== undefined) {
const t = String(name).trim() const t = String(name).trim()
@@ -159,6 +163,11 @@ router.put('/:id', async (req, res, next) => {
} else if (icon === null) { } else if (icon === null) {
data.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 if (archived !== undefined) data.archived = !!archived
// 项目信息块 (T-PJ-13): 任意多个 { title, content }, 整体替换 // 项目信息块 (T-PJ-13): 任意多个 { title, content }, 整体替换
if (notes !== undefined) { if (notes !== undefined) {
+84 -1
View File
@@ -8,10 +8,13 @@ import {
import { resolveTaskWrite } from '../task-domain/resolveStatus.js' import { resolveTaskWrite } from '../task-domain/resolveStatus.js'
import { filterVisible } from '../task-domain/retention.js' import { filterVisible } from '../task-domain/retention.js'
import { copyTask } from '../task-domain/copyTask.js' import { copyTask } from '../task-domain/copyTask.js'
import { runAutoArchiveForProject } from '../task-domain/archive.js'
const router = Router() const router = Router()
router.use(authRequired) router.use(authRequired)
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/
const TASK_INCLUDE = { const TASK_INCLUDE = {
column: true, column: true,
assignee: { select: { id: true, name: true } }, assignee: { select: { id: true, name: true } },
@@ -19,6 +22,7 @@ const TASK_INCLUDE = {
members: { include: { user: { select: { id: true, name: true } } } }, members: { include: { user: { select: { id: true, name: true } } } },
tags: true, tags: true,
children: { children: {
where: { archived: false },
include: { include: {
column: true, column: true,
assignee: { select: { id: true, name: 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) const project = await loadProjectForUser(req.params.id, userId)
if (!project) return res.status(404).json({ error: '项目不存在或无权限' }) if (!project) return res.status(404).json({ error: '项目不存在或无权限' })
// 读时惰性自动归档 (T-ARC-02): 保证刚到期的隐藏任务当场消失
await runAutoArchiveForProject(project.id)
const columns = await loadColumns(project.id) const columns = await loadColumns(project.id)
const rows = await prisma.task.findMany({ const rows = await prisma.task.findMany({
where: { projectId: project.id, parentId: null }, where: { projectId: project.id, parentId: null, archived: false },
include: TASK_INCLUDE, include: TASK_INCLUDE,
orderBy: [{ sortOrder: 'asc' }, { createdAt: 'asc' }], orderBy: [{ sortOrder: 'asc' }, { createdAt: 'asc' }],
}) })
@@ -63,6 +70,82 @@ router.get('/projects/:id/tasks', async (req, res, next) => {
} catch (e) { next(e) } } 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? } // POST /api/projects/:id/tasks { title, description?, parentId?, assigneeId?, memberIds?, tags? }
router.post('/projects/:id/tasks', async (req, res, next) => { router.post('/projects/:id/tasks', async (req, res, next) => {
try { try {
+112
View File
@@ -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
}
+3
View File
@@ -38,4 +38,7 @@ export const taskApi = {
reorder: (projectId, ids) => api(`/projects/${projectId}/tasks/reorder`, { method: 'POST', body: { ids } }), reorder: (projectId, ids) => api(`/projects/${projectId}/tasks/reorder`, { method: 'POST', body: { ids } }),
remove: (id) => api(`/tasks/${id}`, { method: 'DELETE' }), remove: (id) => api(`/tasks/${id}`, { method: 'DELETE' }),
copy: (id, projectId) => api(`/tasks/${id}/copy`, { method: 'POST', body: { projectId } }), 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)),
} }
+208
View File
@@ -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 (
<Popup
visible={visible}
onMaskClick={onClose}
position="right"
bodyStyle={{ width: '100vw', height: '100vh', overflowY: 'auto', background: 'var(--color-bg)' }}
>
<div className="cs-page" style={{ position: 'static', height: 'auto', minHeight: '100vh' }}>
<div className="navbar-sub">
<span className="back" onClick={onClose}>‹</span>
<span className="title">已归档任务</span>
</div>
<div style={{ padding: '12px 14px', display: 'flex', flexDirection: 'column', gap: 8 }}>
<Input
placeholder="搜索任务标题"
value={filters.keyword}
clearable
onEnter={() => load(true)}
onChange={(v) => setF({ keyword: v })}
onBlur={() => load(true)}
/>
<div style={{ display: 'flex', gap: 8 }}>
<div style={{ flex: 1 }}>
<Selector
options={[{ label: '全部类型', value: '' }, ...kinds.map((k) => ({ label: k, value: k }))]}
value={[filters.kind]}
onSelect={(_, ext) => { setF({ kind: ext.items[0].value }); setTimeout(() => load(true), 0) }}
/>
</div>
</div>
<Selector
options={[{ label: '全部看板列', value: '' }, ...columns.map((c) => ({ label: c.name, value: String(c.id) }))]}
value={[filters.columnId]}
onSelect={(_, ext) => { setF({ columnId: ext.items[0].value }); setTimeout(() => load(true), 0) }}
/>
<div style={{ display: 'flex', gap: 8, alignItems: 'center', fontSize: 12, color: '#999' }}>
<span>归档</span>
<span className="mini-btn" style={{ padding: '3px 10px' }} onClick={() => setFromVisible(true)}>
{filters.from || '开始日期'}
</span>
<span>~</span>
<span className="mini-btn" style={{ padding: '3px 10px' }} onClick={() => setToVisible(true)}>
{filters.to || '结束日期'}
</span>
{(filters.from || filters.to) && (
<span className="link" onClick={() => { setF({ from: '', to: '' }); setTimeout(() => load(true), 0) }}>清除</span>
)}
</div>
</div>
<DatePicker
visible={fromVisible}
onClose={() => 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)
}}
/>
<DatePicker
visible={toVisible}
onClose={() => 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)
}}
/>
<div style={{ padding: '0 14px 24px', display: 'flex', flexDirection: 'column', gap: 7 }}>
{items === null && loading && <div style={{ textAlign: 'center', padding: 30 }}><DotLoading /> 加载中...</div>}
{items !== null && items.length === 0 && !loading && (
<div style={{ textAlign: 'center', color: '#b8bcc4', padding: 40 }}>没有符合条件的归档任务</div>
)}
{(items || []).map((t) => (
<div
key={t.id}
style={{
background: '#fff', borderRadius: 9, padding: '10px 12px', display: 'flex', gap: 10, alignItems: 'flex-start',
boxShadow: '0 1px 2px rgba(0,0,0,.04)',
}}
>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 13.5, fontWeight: 600, wordBreak: 'break-word' }}>{t.title}</div>
<div style={{ fontSize: 10.5, color: '#999', marginTop: 4, display: 'flex', gap: 6, alignItems: 'center', flexWrap: 'wrap' }}>
{t.kind && <span className="kind-tag" style={{ background: kindColor(t.kind) }}>{t.kind}</span>}
<span>{colName(t.columnId)}</span>
<span>归档于 {fmt(t.archivedAt)}</span>
</div>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 5, flexShrink: 0 }}>
<Button size="mini" color="primary" fill="outline" onClick={() => doRestore(t)}>恢复</Button>
<Button size="mini" color="danger" fill="none" onClick={() => doRemove(t)}>删除</Button>
</div>
</div>
))}
{nextCursor && (
<Button
block
fill="outline"
size="small"
loading={loading}
onClick={() => load(false)}
style={{ marginTop: 4 }}
>
加载更多
</Button>
)}
</div>
</div>
</Popup>
)
}
+36 -1
View File
@@ -1,5 +1,5 @@
import { useRef, useState } from 'react' 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 { projectApi } from '../../api/task.js'
import { useAuth } from '../../auth/AuthContext.jsx' import { useAuth } from '../../auth/AuthContext.jsx'
import useKeyboardInset from '../../hooks/useKeyboardInset.js' import useKeyboardInset from '../../hooks/useKeyboardInset.js'
@@ -24,6 +24,15 @@ export default function ProjectManage({ project, onClose, onChanged, onExited })
const [newKind, setNewKind] = useState('') const [newKind, setNewKind] = useState('')
// 项目图标更换: 隐藏 file input // 项目图标更换: 隐藏 file input
const iconInputRef = useRef(null) 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 members = project.members || []
const notes = Array.isArray(project.notes) ? project.notes : [] const notes = Array.isArray(project.notes) ? project.notes : []
@@ -238,6 +247,14 @@ export default function ProjectManage({ project, onClose, onChanged, onExited })
<span className="link">列设置 ›</span> <span className="link">列设置 ›</span>
</div> </div>
<div className="section-h" style={{ margin: '14px 14px 8px' }}>归档设置</div>
<div className="set-row" style={{ padding: '12px 14px' }} onClick={() => setArchivePick(true)}>
<span className="pn" style={{ flex: 1, fontSize: 13.5 }}>隐藏的任务自动归档</span>
<span style={{ fontSize: 12.5, color: '#999' }}>
{AUTO_ARCHIVE_LABEL[project.autoArchive] || '永不'} ›
</span>
</div>
<div className="section-h" style={{ margin: '14px 14px 8px' }}>任务类型</div> <div className="section-h" style={{ margin: '14px 14px 8px' }}>任务类型</div>
<div className="set-row" style={{ padding: '12px 14px' }} onClick={() => { setKinds(project.taskKinds || DEFAULT_KINDS); setNewKind(''); setKindEdit(true) }}> <div className="set-row" style={{ padding: '12px 14px' }} onClick={() => { setKinds(project.taskKinds || DEFAULT_KINDS); setNewKind(''); setKindEdit(true) }}>
<div className="kind-chip-row" style={{ flex: 1 }}> <div className="kind-chip-row" style={{ flex: 1 }}>
@@ -328,6 +345,24 @@ export default function ProjectManage({ project, onClose, onChanged, onExited })
onClose={() => setRenaming(false)} onClose={() => setRenaming(false)}
/> />
{/* 自动归档档位选择 (T-ARC-02) */}
<Picker
visible={archivePick}
onClose={() => 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 删除 + 输入新增 */} {/* 任务类型编辑: chips 删除 + 输入新增 */}
<Popup <Popup
visible={kindEdit} visible={kindEdit}
+28
View File
@@ -9,6 +9,7 @@ import BoardView from './BoardView.jsx'
import TaskDetail from './TaskDetail.jsx' import TaskDetail from './TaskDetail.jsx'
import ProjectManage from './ProjectManage.jsx' import ProjectManage from './ProjectManage.jsx'
import CopyTaskPopup from '../../components/task/CopyTaskPopup.jsx' import CopyTaskPopup from '../../components/task/CopyTaskPopup.jsx'
import ArchivedTasks from './ArchivedTasks.jsx'
// 项目视图容器: 列表 / 看板 切换 + 数据装载 + 乐观更新 // 项目视图容器: 列表 / 看板 切换 + 数据装载 + 乐观更新
// 嵌在任务首屏下部 (T-VW-09); 头部仅保留「项目管理」与「+ 新增」(T-PJ-11) // 嵌在任务首屏下部 (T-VW-09); 头部仅保留「项目管理」与「+ 新增」(T-PJ-11)
@@ -23,6 +24,7 @@ export default function ProjectView({ projectId, onProjectChanged, onProjectExit
const [adding, setAdding] = useState(false) const [adding, setAdding] = useState(false)
const [tick, setTick] = useState(0) // 保留窗口重算触发 const [tick, setTick] = useState(0) // 保留窗口重算触发
const [copyTask, setCopyTask] = useState(null) // ⋯菜单触发的跨项目拷贝 const [copyTask, setCopyTask] = useState(null) // ⋯菜单触发的跨项目拷贝
const [archivedOpen, setArchivedOpen] = useState(false) // 已归档任务查询
const load = useCallback(() => { const load = useCallback(() => {
setErr('') setErr('')
@@ -169,6 +171,7 @@ export default function ProjectView({ projectId, onProjectChanged, onProjectExit
const handler = ActionSheet.show({ const handler = ActionSheet.show({
actions: [ actions: [
{ text: '编辑', key: 'edit' }, { text: '编辑', key: 'edit' },
{ text: '归档', key: 'archive' },
{ text: '拷贝到其他项目', key: 'copy' }, { text: '拷贝到其他项目', key: 'copy' },
{ text: '删除', key: 'delete', bold: true, danger: true }, { text: '删除', key: 'delete', bold: true, danger: true },
], ],
@@ -176,6 +179,21 @@ export default function ProjectView({ projectId, onProjectChanged, onProjectExit
onAction: async (item) => { onAction: async (item) => {
handler.close?.() handler.close?.()
if (item.key === 'edit') setOpenTask(task) 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 === 'copy') setCopyTask(task)
else if (item.key === 'delete') { else if (item.key === 'delete') {
const kids = task.children?.length || 0 const kids = task.children?.length || 0
@@ -225,6 +243,7 @@ export default function ProjectView({ projectId, onProjectChanged, onProjectExit
<div className="navbar-sub task-projbar"> <div className="navbar-sub task-projbar">
<span className="title">{project.name}</span> <span className="title">{project.name}</span>
<div className="right"> <div className="right">
<span className="mini-btn" onClick={() => setArchivedOpen(true)}>已归档</span>
<span className="mini-btn" onClick={() => setShowManage(true)}>项目管理</span> <span className="mini-btn" onClick={() => setShowManage(true)}>项目管理</span>
<span className="mini-btn solid" onClick={() => setAdding((v) => !v)}>+ 新增</span> <span className="mini-btn solid" onClick={() => setAdding((v) => !v)}>+ 新增</span>
</div> </div>
@@ -301,6 +320,15 @@ export default function ProjectView({ projectId, onProjectChanged, onProjectExit
/> />
)} )}
{archivedOpen && (
<ArchivedTasks
project={project}
visible={archivedOpen}
onClose={() => setArchivedOpen(false)}
onChanged={load}
/>
)}
{showManage && ( {showManage && (
<ProjectManage <ProjectManage
project={project} project={project}