Skip to content

Commit 18b6bda

Browse files
author
shuanbao
committed
feat: 任务面板停滞检测 + 已完成任务归档
- 停滞检测:in_progress/review/rework 超过 24h 的任务标记琥珀色停滞标签,批量操作栏支持移回待办或标记失败 - 看板折叠:completed/failed 列默认只显示 5 个,底部展开/收起按钮 - 列表分区:活跃任务与终态任务分开显示,终态默认折叠且 opacity-60 - 清除已完成:header 新增按钮,确认后批量删除所有 completed/failed 任务 - 新增 DELETE /api/tasks/batch 批量删除端点(支持 standalone + project 任务) - i18n:zh/en 新增 10 个翻译 key Co-Authored-By: shuanbao <[email protected]>
1 parent 14ac0a9 commit 18b6bda

6 files changed

Lines changed: 307 additions & 14 deletions

File tree

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import { NextRequest, NextResponse } from 'next/server'
2+
import { existsSync, readdirSync } from 'fs'
3+
import { join, resolve } from 'path'
4+
import {
5+
readStandaloneTasks,
6+
writeStandaloneTasks,
7+
readProjectMeta,
8+
writeProjectMeta,
9+
} from '@/lib/task-storage'
10+
11+
export const dynamic = 'force-dynamic'
12+
13+
const PROJECT_ROOT = resolve(process.cwd(), '..')
14+
const PROJECTS_DIR = join(PROJECT_ROOT, 'projects')
15+
16+
// ── DELETE /api/tasks/batch ─────────────────────────────────────
17+
// Body: { statuses: string[], olderThanDays?: number }
18+
19+
export async function DELETE(req: NextRequest) {
20+
try {
21+
const body = await req.json()
22+
const { statuses, olderThanDays } = body as { statuses: string[]; olderThanDays?: number }
23+
24+
if (!Array.isArray(statuses) || statuses.length === 0) {
25+
return NextResponse.json({ error: 'statuses array required' }, { status: 400 })
26+
}
27+
28+
const statusSet = new Set(statuses)
29+
const cutoff = olderThanDays != null
30+
? Date.now() - olderThanDays * 24 * 60 * 60 * 1000
31+
: null
32+
33+
const shouldRemove = (task: Record<string, unknown>): boolean => {
34+
if (!statusSet.has(task.status as string)) return false
35+
if (cutoff != null) {
36+
const updatedAt = new Date(task.updatedAt as string).getTime()
37+
if (updatedAt > cutoff) return false
38+
}
39+
return true
40+
}
41+
42+
let deleted = 0
43+
44+
// 1. Standalone tasks
45+
const standalone = readStandaloneTasks()
46+
const kept = standalone.filter(t => !shouldRemove(t as unknown as Record<string, unknown>))
47+
deleted += standalone.length - kept.length
48+
if (kept.length !== standalone.length) {
49+
writeStandaloneTasks(kept)
50+
}
51+
52+
// 2. Project tasks
53+
if (existsSync(PROJECTS_DIR)) {
54+
const dirs = readdirSync(PROJECTS_DIR, { withFileTypes: true }).filter(d => d.isDirectory())
55+
for (const dir of dirs) {
56+
// Top-level project
57+
deleted += filterProjectTasks(dir.name, shouldRemove)
58+
// Sub-projects
59+
try {
60+
const subDirs = readdirSync(join(PROJECTS_DIR, dir.name), { withFileTypes: true })
61+
.filter(sd => sd.isDirectory() && !sd.name.startsWith('.'))
62+
for (const sd of subDirs) {
63+
deleted += filterProjectTasks(`${dir.name}/${sd.name}`, shouldRemove)
64+
}
65+
} catch { /* skip */ }
66+
}
67+
}
68+
69+
return NextResponse.json({ ok: true, deleted })
70+
} catch (err) {
71+
return NextResponse.json({ error: String(err) }, { status: 500 })
72+
}
73+
}
74+
75+
function filterProjectTasks(
76+
projectId: string,
77+
shouldRemove: (t: Record<string, unknown>) => boolean
78+
): number {
79+
const meta = readProjectMeta(projectId)
80+
if (!meta || !Array.isArray(meta.tasks)) return 0
81+
const tasks = meta.tasks as Record<string, unknown>[]
82+
const kept = tasks.filter(t => !shouldRemove(t))
83+
const removed = tasks.length - kept.length
84+
if (removed > 0) {
85+
meta.tasks = kept
86+
writeProjectMeta(projectId, meta)
87+
}
88+
return removed
89+
}

ui/src/app/tasks/page.tsx

Lines changed: 173 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,13 @@ import { useState, useEffect, useMemo, useCallback } from 'react'
33
import { useTranslation } from '@/lib/i18n'
44
import { useAppStore } from '@/lib/store'
55
import { Task } from '@/lib/types'
6-
import { TaskCard } from '@/components/task-card'
6+
import { TaskCard, isTaskStale } from '@/components/task-card'
77
import { TaskForm } from '@/components/task-form'
88
import { TaskPipeline } from '@/components/task-pipeline'
99
import { TaskQuality } from '@/components/task-quality'
1010
import {
1111
CheckSquare, Plus, LayoutGrid, List, X,
12-
ChevronRight, User, FolderKanban, Clock, Trash2, Edit3, Tag, Shield
12+
ChevronRight, User, FolderKanban, Clock, Trash2, Edit3, Tag, Shield, AlertTriangle
1313
} from 'lucide-react'
1414
import { cn } from '@/lib/utils'
1515

@@ -64,6 +64,8 @@ export default function TasksPage() {
6464
const [filterAgent, setFilterAgent] = useState('')
6565
const [filterStatus, setFilterStatus] = useState('')
6666
const [filterType, setFilterType] = useState('')
67+
const [expandedCols, setExpandedCols] = useState<Set<string>>(new Set())
68+
const [showTerminal, setShowTerminal] = useState(false)
6769

6870
useEffect(() => { fetchTasks() }, [fetchTasks])
6971
useEffect(() => { localStorage.setItem('af-task-view', view) }, [view])
@@ -89,6 +91,11 @@ export default function TasksPage() {
8991
return result
9092
}, [tasks, filterProject, filterAgent, filterStatus, filterType])
9193

94+
const staleTasks = useMemo(() => filtered.filter(isTaskStale), [filtered])
95+
const terminalStatuses = useMemo(() => new Set(['completed', 'failed']), [])
96+
const activeTasks = useMemo(() => filtered.filter(t => !terminalStatuses.has(t.status)), [filtered, terminalStatuses])
97+
const terminalTasks = useMemo(() => filtered.filter(t => terminalStatuses.has(t.status)), [filtered, terminalStatuses])
98+
9299
// Group for kanban by status
93100
const kanbanGroups = useMemo(() => {
94101
if (groupBy === 'status') {
@@ -164,6 +171,40 @@ export default function TasksPage() {
164171
} catch { /* ignore */ }
165172
}, [fetchTasks, t])
166173

174+
const handleStaleMoveTodo = useCallback(async () => {
175+
for (const task of staleTasks) {
176+
await fetch('/api/tasks', {
177+
method: 'PUT',
178+
headers: { 'Content-Type': 'application/json' },
179+
body: JSON.stringify({ id: task.id, status: 'pending' }),
180+
})
181+
}
182+
fetchTasks()
183+
}, [staleTasks, fetchTasks])
184+
185+
const handleStaleMarkFailed = useCallback(async () => {
186+
for (const task of staleTasks) {
187+
await fetch('/api/tasks', {
188+
method: 'PUT',
189+
headers: { 'Content-Type': 'application/json' },
190+
body: JSON.stringify({ id: task.id, status: 'failed' }),
191+
})
192+
}
193+
fetchTasks()
194+
}, [staleTasks, fetchTasks])
195+
196+
const handleClearCompleted = useCallback(async () => {
197+
if (!confirm(t('tasks.confirmClearCompleted'))) return
198+
try {
199+
await fetch('/api/tasks/batch', {
200+
method: 'DELETE',
201+
headers: { 'Content-Type': 'application/json' },
202+
body: JSON.stringify({ statuses: ['completed', 'failed'] }),
203+
})
204+
fetchTasks()
205+
} catch { /* ignore */ }
206+
}, [fetchTasks, t])
207+
167208
// Unique project names for filter
168209
const projectOptions = useMemo(() => {
169210
const ids = new Set(tasks.map(t => t.projectId).filter(Boolean) as string[])
@@ -219,6 +260,15 @@ export default function TasksPage() {
219260
<List className="w-4 h-4" />
220261
</button>
221262
</div>
263+
{terminalTasks.length > 0 && (
264+
<button
265+
onClick={handleClearCompleted}
266+
className="flex items-center gap-1.5 px-3 py-2 text-sm rounded-lg border border-border text-muted-foreground hover:text-foreground hover:border-red-500/30 transition-colors"
267+
>
268+
<Trash2 className="w-4 h-4" />
269+
{t('tasks.clearCompleted')}
270+
</button>
271+
)}
222272
<button
223273
onClick={() => { setEditTask(null); setShowForm(true) }}
224274
className="flex items-center gap-1.5 px-3 py-2 bg-primary text-primary-foreground text-sm rounded-lg hover:bg-primary/90 transition-colors"
@@ -278,6 +328,30 @@ export default function TasksPage() {
278328
</select>
279329
</div>
280330

331+
{/* Stale tasks banner */}
332+
{staleTasks.length > 0 && (
333+
<div className="flex items-center justify-between gap-3 px-4 py-2.5 rounded-lg bg-amber-500/10 border border-amber-500/20">
334+
<div className="flex items-center gap-2 text-sm text-amber-400">
335+
<AlertTriangle className="w-4 h-4 shrink-0" />
336+
<span>{t('tasks.staleTasks').replace('{count}', String(staleTasks.length))}</span>
337+
</div>
338+
<div className="flex items-center gap-2">
339+
<button
340+
onClick={handleStaleMoveTodo}
341+
className="px-2.5 py-1 text-xs rounded-md bg-amber-500/20 text-amber-400 hover:bg-amber-500/30 transition-colors"
342+
>
343+
{t('tasks.staleMoveTodo')}
344+
</button>
345+
<button
346+
onClick={handleStaleMarkFailed}
347+
className="px-2.5 py-1 text-xs rounded-md bg-amber-500/20 text-amber-400 hover:bg-amber-500/30 transition-colors"
348+
>
349+
{t('tasks.staleMarkFailed')}
350+
</button>
351+
</div>
352+
</div>
353+
)}
354+
281355
{/* Empty state */}
282356
{tasks.length === 0 && (
283357
<div className="text-center py-16 space-y-3">
@@ -309,9 +383,38 @@ export default function TasksPage() {
309383
</span>
310384
</div>
311385
<div className="space-y-2 min-h-[200px] p-1.5 rounded-lg bg-muted/30 border border-border/50">
312-
{(kanbanGroups[col.key] || []).map(task => (
313-
<TaskCard key={task.id} task={task} onClick={() => setDetailTask(task)} />
314-
))}
386+
{(() => {
387+
const colTasks = kanbanGroups[col.key] || []
388+
const isTerminalCol = groupBy === 'status' && (col.key === 'completed' || col.key === 'failed')
389+
const maxCollapsed = 5
390+
const isExpanded = expandedCols.has(col.key)
391+
const visible = isTerminalCol && !isExpanded && colTasks.length > maxCollapsed
392+
? colTasks.slice(0, maxCollapsed)
393+
: colTasks
394+
const remaining = colTasks.length - visible.length
395+
return (
396+
<>
397+
{visible.map(task => (
398+
<TaskCard key={task.id} task={task} onClick={() => setDetailTask(task)} />
399+
))}
400+
{isTerminalCol && colTasks.length > maxCollapsed && (
401+
<button
402+
onClick={() => setExpandedCols(prev => {
403+
const next = new Set(prev)
404+
if (next.has(col.key)) next.delete(col.key)
405+
else next.add(col.key)
406+
return next
407+
})}
408+
className="w-full text-center text-xs text-muted-foreground hover:text-foreground py-1.5 transition-colors"
409+
>
410+
{isExpanded
411+
? t('tasks.showLess')
412+
: t('tasks.showMore').replace('{count}', String(remaining))}
413+
</button>
414+
)}
415+
</>
416+
)
417+
})()}
315418
</div>
316419
</div>
317420
))}
@@ -334,7 +437,7 @@ export default function TasksPage() {
334437
</tr>
335438
</thead>
336439
<tbody className="divide-y divide-border">
337-
{filtered.map(task => (
440+
{activeTasks.map(task => (
338441
<tr
339442
key={task.id}
340443
onClick={() => setDetailTask(task)}
@@ -376,6 +479,70 @@ export default function TasksPage() {
376479
</tr>
377480
))}
378481
</tbody>
482+
{terminalTasks.length > 0 && (
483+
<>
484+
<tbody>
485+
<tr>
486+
<td colSpan={7} className="px-4 py-2">
487+
<button
488+
onClick={() => setShowTerminal(v => !v)}
489+
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
490+
>
491+
<ChevronRight className={cn('w-3.5 h-3.5 transition-transform', showTerminal && 'rotate-90')} />
492+
{showTerminal
493+
? t('tasks.hideCompleted')
494+
: t('tasks.showCompleted').replace('{count}', String(terminalTasks.length))}
495+
</button>
496+
</td>
497+
</tr>
498+
</tbody>
499+
{showTerminal && (
500+
<tbody className="divide-y divide-border opacity-60">
501+
{terminalTasks.map(task => (
502+
<tr
503+
key={task.id}
504+
onClick={() => setDetailTask(task)}
505+
className="hover:bg-muted/30 cursor-pointer transition-colors"
506+
>
507+
<td className="px-4 py-2.5">
508+
<span className={cn('inline-block w-2.5 h-2.5 rounded-full', statusDot[task.status] || 'bg-zinc-400')} />
509+
</td>
510+
<td className="px-4 py-2.5 text-foreground font-medium truncate max-w-[200px]">{task.name}</td>
511+
<td className="px-4 py-2.5 text-muted-foreground hidden md:table-cell">
512+
{task.type ? (
513+
<span className="text-[10px] px-1.5 py-0.5 rounded bg-primary/10 text-primary">{task.type}</span>
514+
) : '-'}
515+
</td>
516+
<td className="px-4 py-2.5 text-muted-foreground hidden md:table-cell">
517+
{task.assignees?.join(', ') || '-'}
518+
</td>
519+
<td className="px-4 py-2.5 text-muted-foreground hidden lg:table-cell">
520+
{task.projectId || t('tasks.standalone')}
521+
</td>
522+
<td className="px-4 py-2.5">
523+
<span className={cn(
524+
'text-[10px] font-bold px-1.5 py-0.5 rounded',
525+
task.priority === 'P0' ? 'bg-red-500/20 text-red-400'
526+
: task.priority === 'P1' ? 'bg-amber-500/20 text-amber-400'
527+
: 'bg-blue-500/20 text-blue-400'
528+
)}>
529+
{task.priority}
530+
</span>
531+
</td>
532+
<td className="px-4 py-2.5 hidden md:table-cell">
533+
<div className="flex items-center gap-2">
534+
<div className="w-16 h-1.5 rounded-full bg-muted overflow-hidden">
535+
<div className="h-full bg-primary rounded-full" style={{ width: `${task.progress}%` }} />
536+
</div>
537+
<span className="text-xs text-muted-foreground">{task.progress}%</span>
538+
</div>
539+
</td>
540+
</tr>
541+
))}
542+
</tbody>
543+
)}
544+
</>
545+
)}
379546
</table>
380547
</div>
381548
)}

ui/src/components/task-card.tsx

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
import { Task } from '@/lib/types'
33
import { useTranslation } from '@/lib/i18n'
44
import { cn } from '@/lib/utils'
5-
import { Clock, User, FolderKanban, Shield, Tag } from 'lucide-react'
5+
import { Clock, User, FolderKanban, Shield, Tag, AlertTriangle } from 'lucide-react'
66

77
interface TaskCardProps {
88
task: Task
@@ -25,6 +25,13 @@ const statusColors: Record<string, string> = {
2525
failed: 'bg-red-500/20 text-red-400',
2626
}
2727

28+
const STALE_THRESHOLD_MS = 24 * 60 * 60 * 1000
29+
30+
export function isTaskStale(task: Task): boolean {
31+
if (!['in_progress', 'review', 'rework'].includes(task.status)) return false
32+
return Date.now() - new Date(task.updatedAt).getTime() > STALE_THRESHOLD_MS
33+
}
34+
2835
function timeAgo(dateStr: string): string {
2936
const diff = Date.now() - new Date(dateStr).getTime()
3037
const mins = Math.floor(diff / 60000)
@@ -38,24 +45,34 @@ function timeAgo(dateStr: string): string {
3845

3946
export function TaskCard({ task, onClick }: TaskCardProps) {
4047
const { t } = useTranslation()
48+
const stale = isTaskStale(task)
4149

4250
return (
4351
<div
4452
onClick={onClick}
4553
className={cn(
4654
'p-3 rounded-lg border border-border bg-card/80 hover:bg-card',
4755
'cursor-pointer transition-all hover:shadow-md hover:border-primary/30',
48-
'space-y-2'
56+
'space-y-2',
57+
stale && 'border-l-2 border-l-amber-500/60'
4958
)}
5059
>
5160
{/* Header: priority + status */}
5261
<div className="flex items-center justify-between gap-2">
5362
<span className={cn('text-[10px] font-bold px-1.5 py-0.5 rounded border', priorityColors[task.priority] || priorityColors.P1)}>
5463
{task.priority}
5564
</span>
56-
<span className={cn('text-[10px] px-1.5 py-0.5 rounded', statusColors[task.status] || statusColors.pending)}>
57-
{t(`tasks.col${task.status === 'pending' || task.status === 'assigned' ? 'Pending' : task.status === 'in_progress' ? 'InProgress' : task.status === 'review' ? 'Review' : task.status === 'rework' ? 'Rework' : task.status === 'completed' ? 'Completed' : 'Failed'}`)}
58-
</span>
65+
<div className="flex items-center gap-1">
66+
<span className={cn('text-[10px] px-1.5 py-0.5 rounded', statusColors[task.status] || statusColors.pending)}>
67+
{t(`tasks.col${task.status === 'pending' || task.status === 'assigned' ? 'Pending' : task.status === 'in_progress' ? 'InProgress' : task.status === 'review' ? 'Review' : task.status === 'rework' ? 'Rework' : task.status === 'completed' ? 'Completed' : 'Failed'}`)}
68+
</span>
69+
{stale && (
70+
<span className="flex items-center gap-0.5 text-[10px] px-1.5 py-0.5 rounded bg-amber-500/20 text-amber-400">
71+
<AlertTriangle className="w-2.5 h-2.5" />
72+
{t('tasks.stale')}
73+
</span>
74+
)}
75+
</div>
5976
</div>
6077

6178
{/* Task name + type badge */}

0 commit comments

Comments
 (0)