- 数据: 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:
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user