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
+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 { 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 })
<span className="link">列设置 ›</span>
</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="set-row" style={{ padding: '12px 14px' }} onClick={() => { setKinds(project.taskKinds || DEFAULT_KINDS); setNewKind(''); setKindEdit(true) }}>
<div className="kind-chip-row" style={{ flex: 1 }}>
@@ -328,6 +345,24 @@ export default function ProjectManage({ project, onClose, onChanged, onExited })
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 删除 + 输入新增 */}
<Popup
visible={kindEdit}
+28
View File
@@ -9,6 +9,7 @@ import BoardView from './BoardView.jsx'
import TaskDetail from './TaskDetail.jsx'
import ProjectManage from './ProjectManage.jsx'
import CopyTaskPopup from '../../components/task/CopyTaskPopup.jsx'
import ArchivedTasks from './ArchivedTasks.jsx'
// 项目视图容器: 列表 / 看板 切换 + 数据装载 + 乐观更新
// 嵌在任务首屏下部 (T-VW-09); 头部仅保留「项目管理」与「+ 新增」(T-PJ-11)
@@ -23,6 +24,7 @@ export default function ProjectView({ projectId, onProjectChanged, onProjectExit
const [adding, setAdding] = useState(false)
const [tick, setTick] = useState(0) // 保留窗口重算触发
const [copyTask, setCopyTask] = useState(null) // ⋯菜单触发的跨项目拷贝
const [archivedOpen, setArchivedOpen] = useState(false) // 已归档任务查询
const load = useCallback(() => {
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
<div className="navbar-sub task-projbar">
<span className="title">{project.name}</span>
<div className="right">
<span className="mini-btn" onClick={() => setArchivedOpen(true)}>已归档</span>
<span className="mini-btn" onClick={() => setShowManage(true)}>项目管理</span>
<span className="mini-btn solid" onClick={() => setAdding((v) => !v)}>+ 新增</span>
</div>
@@ -301,6 +320,15 @@ export default function ProjectView({ projectId, onProjectChanged, onProjectExit
/>
)}
{archivedOpen && (
<ArchivedTasks
project={project}
visible={archivedOpen}
onClose={() => setArchivedOpen(false)}
onChanged={load}
/>
)}
{showManage && (
<ProjectManage
project={project}