feat: Kanban board with five-column view, drag-and-drop, detail drawer, anomaly detection (Phase 7)
Backend: new kanban API with mock data, five-column task aggregation, task detail, drag-to-move with block reason validation, and server-side anomaly detection (blocked_timeout, unclaimed, retry_failure). Frontend: full kanban page with summary bar + filters, five-column board with BLOCKED orange border, task cards with color-coded left bar and anomaly highlighting, HTML5 native drag-and-drop (desktop only), block reason modal, done confirmation, 480px detail drawer with meta/description/ dependencies/activity timeline, floating batch operations bar, and optimistic updates with rollback. Drawer component: title prop changed from string to ReactNode to support rich title content. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,55 @@
|
||||
import client from './client'
|
||||
|
||||
export interface KanbanTask {
|
||||
id: string
|
||||
title: string
|
||||
board: string
|
||||
worker_profile: string
|
||||
worker_type: string
|
||||
priority: number
|
||||
column: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
goal_mode: boolean
|
||||
retries: number
|
||||
failures: number
|
||||
description: string
|
||||
block_reason: string | null
|
||||
dependencies: string[]
|
||||
dependents: string[]
|
||||
activities: { time: string; user: string; action: string }[]
|
||||
anomalies: string[]
|
||||
dependency_tasks?: { id: string; title: string; column: string }[]
|
||||
dependent_tasks?: { id: string; title: string; column: string }[]
|
||||
}
|
||||
|
||||
export interface KanbanOverview {
|
||||
total_tasks: number
|
||||
profile_count: number
|
||||
board_count: number
|
||||
running_count: number
|
||||
blocked_count: number
|
||||
}
|
||||
|
||||
export interface KanbanResponse {
|
||||
todo: KanbanTask[]
|
||||
ready: KanbanTask[]
|
||||
running: KanbanTask[]
|
||||
blocked: KanbanTask[]
|
||||
done: KanbanTask[]
|
||||
overview: KanbanOverview
|
||||
}
|
||||
|
||||
export interface MoveResponse {
|
||||
success: boolean
|
||||
task: KanbanTask
|
||||
}
|
||||
|
||||
export const getKanbanTasks = (params?: { worker?: string; board?: string; anomaly?: string }) =>
|
||||
client.get<KanbanResponse>('/kanban/tasks', { params })
|
||||
|
||||
export const getKanbanTaskDetail = (taskId: string) =>
|
||||
client.get<KanbanTask>(`/kanban/tasks/${taskId}`)
|
||||
|
||||
export const moveKanbanTask = (taskId: string, column: string, blockReason?: string) =>
|
||||
client.patch<MoveResponse>(`/kanban/tasks/${taskId}/move`, { column, block_reason: blockReason })
|
||||
@@ -4,7 +4,7 @@ import { createPortal } from 'react-dom'
|
||||
export interface DrawerProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
title?: string
|
||||
title?: ReactNode
|
||||
children: ReactNode
|
||||
footer?: ReactNode
|
||||
width?: string | number
|
||||
|
||||
@@ -1,3 +1,858 @@
|
||||
export default function KanbanPage() {
|
||||
return <div><h2 style={{ color: 'var(--text)', fontSize: 18, fontWeight: 600 }}>看板追踪</h2><p style={{ color: 'var(--text-dim)', marginTop: 8 }}>Kanban 任务看板</p></div>
|
||||
import { useEffect, useState, useCallback, useRef } from 'react'
|
||||
import Badge from '../components/ui/Badge'
|
||||
import Button from '../components/ui/Button'
|
||||
import Drawer from '../components/ui/Drawer'
|
||||
import Skeleton from '../components/ui/Skeleton'
|
||||
import toast from '../components/ui/Toast'
|
||||
|
||||
import {
|
||||
getKanbanTasks,
|
||||
getKanbanTaskDetail,
|
||||
moveKanbanTask,
|
||||
type KanbanTask,
|
||||
type KanbanOverview,
|
||||
} from '../api/kanban'
|
||||
|
||||
/* ===== Helpers ===== */
|
||||
|
||||
const COLUMN_META: Record<string, { label: string; color: string; bg: string; borderColor: string }> = {
|
||||
todo: { label: 'TODO', color: 'var(--text-dim)', bg: 'transparent', borderColor: 'var(--border)' },
|
||||
ready: { label: 'READY', color: 'var(--blue)', bg: 'rgba(68,138,255,0.06)', borderColor: 'var(--blue)' },
|
||||
running: { label: 'RUNNING', color: 'var(--green)', bg: 'rgba(0,200,83,0.06)', borderColor: 'var(--green)' },
|
||||
blocked: { label: 'BLOCKED', color: 'var(--orange)', bg: 'rgba(255,109,0,0.08)', borderColor: 'var(--orange)' },
|
||||
done: { label: 'DONE', color: 'var(--text-muted)', bg: 'rgba(90,93,106,0.04)', borderColor: 'var(--text-muted)' },
|
||||
}
|
||||
|
||||
const COLUMN_ORDER = ['todo', 'ready', 'running', 'blocked', 'done'] as const
|
||||
|
||||
function relativeTime(iso: string): string {
|
||||
const diff = Date.now() - new Date(iso).getTime()
|
||||
const mins = Math.floor(diff / 60000)
|
||||
if (mins < 1) return 'just now'
|
||||
if (mins < 60) return mins + 'm ago'
|
||||
const hrs = Math.floor(mins / 60)
|
||||
if (hrs < 24) return hrs + 'h ago'
|
||||
const days = Math.floor(hrs / 24)
|
||||
if (days < 30) return days + 'd ago'
|
||||
return Math.floor(days / 30) + 'mo ago'
|
||||
}
|
||||
|
||||
function priorityLabel(p: number): string {
|
||||
if (p === 0) return 'P0'
|
||||
if (p === 1) return 'P1'
|
||||
if (p === 2) return 'P2'
|
||||
return 'P3'
|
||||
}
|
||||
|
||||
function priorityColor(p: number): string {
|
||||
if (p === 0) return 'var(--red)'
|
||||
if (p === 1) return 'var(--yellow)'
|
||||
return 'var(--text-dim)'
|
||||
}
|
||||
|
||||
function leftBarColor(task: KanbanTask): string {
|
||||
if (task.priority === 0) return 'var(--red)'
|
||||
if (task.goal_mode) return 'var(--honey)'
|
||||
if (task.column === 'blocked') return 'var(--orange)'
|
||||
return 'transparent'
|
||||
}
|
||||
|
||||
function anomalyStyle(anomalies: string[]): React.CSSProperties | undefined {
|
||||
if (anomalies.includes('blocked_timeout')) return { border: '1px solid var(--red)', boxShadow: '0 0 0 1px var(--red)' }
|
||||
if (anomalies.includes('unclaimed')) return { border: '1px solid var(--yellow)' }
|
||||
if (anomalies.includes('retry_failure')) return { border: '1px solid var(--red)', boxShadow: '0 0 0 1px var(--red)' }
|
||||
return undefined
|
||||
}
|
||||
|
||||
function isTouchDevice(): boolean {
|
||||
return typeof window !== 'undefined' && ('ontouchstart' in window || navigator.maxTouchPoints > 0)
|
||||
}
|
||||
|
||||
/* ===== TaskCard ===== */
|
||||
|
||||
function TaskCard({
|
||||
task,
|
||||
onDragStart,
|
||||
onCardClick,
|
||||
checked,
|
||||
onCheckChange,
|
||||
}: {
|
||||
task: KanbanTask
|
||||
onDragStart: (e: React.DragEvent, t: KanbanTask) => void
|
||||
onCardClick: (t: KanbanTask) => void
|
||||
checked: boolean
|
||||
onCheckChange: (id: string, v: boolean) => void
|
||||
}) {
|
||||
const lColor = leftBarColor(task)
|
||||
const aStyle = anomalyStyle(task.anomalies)
|
||||
|
||||
return (
|
||||
<div
|
||||
draggable
|
||||
onDragStart={(e) => onDragStart(e, task)}
|
||||
onClick={() => onCardClick(task)}
|
||||
className="rounded-[var(--radius)] bg-[var(--bg-card)] cursor-grab active:cursor-grabbing transition-all duration-150 group relative"
|
||||
style={{
|
||||
padding: '10px 12px',
|
||||
border: '1px solid var(--border)',
|
||||
borderLeft: lColor !== 'transparent' ? '3px solid ' + lColor : '1px solid var(--border)',
|
||||
...(aStyle || {}),
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!aStyle) (e.currentTarget as HTMLElement).style.borderColor = 'var(--honey)'
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!aStyle) (e.currentTarget as HTMLElement).style.borderColor = 'var(--border)'
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="absolute top-2 left-2 opacity-0 group-hover:opacity-100 transition-opacity z-10"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={(e) => onCheckChange(task.id, e.target.checked)}
|
||||
style={{ accentColor: 'var(--honey)', width: 14, height: 14, cursor: 'pointer' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="font-semibold leading-snug mb-1.5"
|
||||
style={{
|
||||
color: 'var(--text)',
|
||||
fontSize: 13,
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 2,
|
||||
WebkitBoxOrient: 'vertical',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{task.title}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 text-[11px]" style={{ color: 'var(--text-dim)' }}>
|
||||
<span>{task.worker_profile}</span>
|
||||
<span>{'\u{1F41D}'}</span>
|
||||
{task.goal_mode && <span title="goal_mode">{'\u{1F3AF}'}</span>}
|
||||
{task.priority === 0 && <span style={{ color: 'var(--red)', fontWeight: 600, fontSize: 10 }}>{'⚡'}P0</span>}
|
||||
{(task.retries > 0 || task.failures > 0) && (
|
||||
<span style={{ color: task.failures > 0 ? 'var(--red)' : 'var(--yellow)', fontSize: 10 }}>
|
||||
{'↻'}{task.retries}/{task.failures}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="text-[10px] mt-1" style={{ color: 'var(--text-muted)' }}>
|
||||
{relativeTime(task.created_at)}
|
||||
</div>
|
||||
|
||||
{task.column === 'blocked' && task.block_reason && (
|
||||
<div
|
||||
className="text-[11px] mt-1.5 truncate"
|
||||
style={{ color: 'var(--orange)' }}
|
||||
title={task.block_reason}
|
||||
>
|
||||
{'⏸'} {task.block_reason}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{task.anomalies.length > 0 && (
|
||||
<div className="flex gap-1 mt-1.5 flex-wrap">
|
||||
{task.anomalies.includes('blocked_timeout') && (
|
||||
<Badge variant="danger" className="!text-[9px]">阻塞超时</Badge>
|
||||
)}
|
||||
{task.anomalies.includes('unclaimed') && (
|
||||
<Badge variant="warning" className="!text-[9px]">长期无主</Badge>
|
||||
)}
|
||||
{task.anomalies.includes('retry_failure') && (
|
||||
<Badge variant="danger" className="!text-[9px]">失败重试</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ===== Kanban Page Component ===== */
|
||||
|
||||
export default function KanbanPage() {
|
||||
const isDesktop = !isTouchDevice()
|
||||
|
||||
/* data */
|
||||
const [tasks, setTasks] = useState<KanbanTask[]>([])
|
||||
const [overview, setOverview] = useState<KanbanOverview | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
/* filters */
|
||||
const [workerFilter, setWorkerFilter] = useState('')
|
||||
const [boardFilter, setBoardFilter] = useState('')
|
||||
const [anomalyFilter, setAnomalyFilter] = useState('')
|
||||
|
||||
/* drawer */
|
||||
const [drawerTask, setDrawerTask] = useState<KanbanTask | null>(null)
|
||||
const [drawerOpen, setDrawerOpen] = useState(false)
|
||||
|
||||
/* drag state */
|
||||
const dragTask = useRef<KanbanTask | null>(null)
|
||||
const [blockReasonModal, setBlockReasonModal] = useState<{ task: KanbanTask; toColumn: string } | null>(null)
|
||||
const [blockReason, setBlockReason] = useState('')
|
||||
const [confirmDone, setConfirmDone] = useState<KanbanTask | null>(null)
|
||||
|
||||
/* batch */
|
||||
const [checkedIds, setCheckedIds] = useState<Set<string>>(new Set())
|
||||
const [batchActing, setBatchActing] = useState(false)
|
||||
|
||||
/* filter options */
|
||||
const [profiles, setProfiles] = useState<string[]>([])
|
||||
const [boards, setBoards] = useState<string[]>([])
|
||||
|
||||
/* ---------- fetch ---------- */
|
||||
|
||||
const fetchTasks = useCallback(async () => {
|
||||
try {
|
||||
const params: Record<string, string> = {}
|
||||
if (workerFilter) params.worker = workerFilter
|
||||
if (boardFilter) params.board = boardFilter
|
||||
if (anomalyFilter) params.anomaly = anomalyFilter
|
||||
const res = await getKanbanTasks(params)
|
||||
const data = res.data
|
||||
const all = [...data.todo, ...data.ready, ...data.running, ...data.blocked, ...data.done]
|
||||
setTasks(all)
|
||||
setOverview(data.overview)
|
||||
setProfiles(Array.from(new Set(all.map(t => t.worker_profile))))
|
||||
setBoards(Array.from(new Set(all.map(t => t.board))))
|
||||
setError(null)
|
||||
} catch {
|
||||
setError('加载看板数据失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [workerFilter, boardFilter, anomalyFilter])
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true)
|
||||
fetchTasks()
|
||||
const id = setInterval(fetchTasks, 30_000)
|
||||
return () => clearInterval(id)
|
||||
}, [fetchTasks])
|
||||
|
||||
/* ---------- helpers: get column tasks ---------- */
|
||||
|
||||
const getColumnTasks = (col: string): KanbanTask[] =>
|
||||
tasks.filter(t => t.column === col).sort((a, b) => a.priority - b.priority)
|
||||
|
||||
/* ---------- drag & drop ---------- */
|
||||
|
||||
const handleDragStart = (e: React.DragEvent, task: KanbanTask) => {
|
||||
if (!isDesktop) { e.preventDefault(); return }
|
||||
dragTask.current = task
|
||||
e.dataTransfer.effectAllowed = 'move'
|
||||
e.dataTransfer.setData('text/plain', task.id)
|
||||
}
|
||||
|
||||
const handleDragOver = (e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
e.dataTransfer.dropEffect = 'move'
|
||||
}
|
||||
|
||||
const handleDrop = async (e: React.DragEvent, column: string) => {
|
||||
e.preventDefault()
|
||||
const task = dragTask.current
|
||||
if (!task || task.column === column) return
|
||||
dragTask.current = null
|
||||
|
||||
if (column === 'blocked') {
|
||||
setBlockReasonModal({ task, toColumn: column })
|
||||
setBlockReason('')
|
||||
return
|
||||
}
|
||||
|
||||
if (column === 'done') {
|
||||
setConfirmDone(task)
|
||||
return
|
||||
}
|
||||
|
||||
await doMove(task, column)
|
||||
}
|
||||
|
||||
const doMove = async (task: KanbanTask, column: string, reason?: string) => {
|
||||
const prevTasks = [...tasks]
|
||||
|
||||
// optimistic update
|
||||
setTasks(prev => prev.map(t => t.id === task.id ? { ...t, column, block_reason: reason || null } : t))
|
||||
|
||||
try {
|
||||
await moveKanbanTask(task.id, column, reason)
|
||||
toast.success('已移动到 ' + COLUMN_META[column].label)
|
||||
} catch {
|
||||
setTasks(prevTasks)
|
||||
toast.error('移动失败,已回滚')
|
||||
}
|
||||
}
|
||||
|
||||
const confirmBlockReason = async () => {
|
||||
if (!blockReasonModal) return
|
||||
if (blockReason.trim().length < 10) {
|
||||
toast.warn('阻塞原因至少 10 字')
|
||||
return
|
||||
}
|
||||
await doMove(blockReasonModal.task, blockReasonModal.toColumn, blockReason.trim())
|
||||
setBlockReasonModal(null)
|
||||
setBlockReason('')
|
||||
}
|
||||
|
||||
const confirmDoneMove = async () => {
|
||||
if (!confirmDone) return
|
||||
await doMove(confirmDone, 'done')
|
||||
setConfirmDone(null)
|
||||
}
|
||||
|
||||
/* ---------- drawer ---------- */
|
||||
|
||||
const openDrawer = async (task: KanbanTask) => {
|
||||
setDrawerTask(task)
|
||||
setDrawerOpen(true)
|
||||
// fetch fresh detail from API
|
||||
try {
|
||||
const res = await getKanbanTaskDetail(task.id)
|
||||
setDrawerTask(res.data)
|
||||
} catch {
|
||||
// use existing data
|
||||
}
|
||||
}
|
||||
|
||||
const closeDrawer = () => {
|
||||
setDrawerOpen(false)
|
||||
setTimeout(() => setDrawerTask(null), 250)
|
||||
}
|
||||
|
||||
/* ---------- batch ---------- */
|
||||
|
||||
const handleCheckChange = (id: string, v: boolean) => {
|
||||
setCheckedIds(prev => {
|
||||
const next = new Set(prev)
|
||||
if (v) next.add(id); else next.delete(id)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const clearSelection = () => setCheckedIds(new Set())
|
||||
|
||||
const batchMove = async (column: string, reason?: string) => {
|
||||
if (checkedIds.size === 0) return
|
||||
setBatchActing(true)
|
||||
const ids = Array.from(checkedIds)
|
||||
let successCount = 0
|
||||
for (const id of ids) {
|
||||
try {
|
||||
await moveKanbanTask(id, column, reason)
|
||||
successCount++
|
||||
} catch {
|
||||
// continue
|
||||
}
|
||||
}
|
||||
if (successCount > 0) {
|
||||
toast.success('已完成 ' + successCount + ' 个任务')
|
||||
setCheckedIds(new Set())
|
||||
fetchTasks()
|
||||
} else {
|
||||
toast.error('批量操作失败')
|
||||
}
|
||||
setBatchActing(false)
|
||||
}
|
||||
|
||||
const batchBlocked = () => {
|
||||
const reason = prompt('请输入阻塞原因(至少 10 字):')
|
||||
if (!reason || reason.trim().length < 10) {
|
||||
toast.warn('阻塞原因至少 10 字')
|
||||
return
|
||||
}
|
||||
batchMove('blocked', reason.trim())
|
||||
}
|
||||
/* ---------- render: loading ---------- */
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div>
|
||||
<h2 style={{ color: 'var(--text)', fontSize: 18, fontWeight: 600, marginBottom: 16 }}>
|
||||
看板追踪
|
||||
</h2>
|
||||
<Skeleton variant="card" count={4} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ---------- render: error ---------- */
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div>
|
||||
<h2 style={{ color: 'var(--text)', fontSize: 18, fontWeight: 600, marginBottom: 16 }}>
|
||||
看板追踪
|
||||
</h2>
|
||||
<div className="flex flex-col items-center justify-center py-12 gap-3" style={{ color: 'var(--text-dim)' }}>
|
||||
<span style={{ fontSize: 32, color: 'var(--red)' }}>⚠</span>
|
||||
<p>{error}</p>
|
||||
<Button variant="outline" onClick={fetchTasks}>重试</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ---------- render ---------- */
|
||||
|
||||
const checkedCount = checkedIds.size
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* ===== Top Summary Bar ===== */}
|
||||
<div className="flex items-center justify-between mb-4 flex-wrap gap-3">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<h2 style={{ color: 'var(--text)', fontSize: 18, fontWeight: 600, marginRight: 8 }}>
|
||||
看板追踪
|
||||
</h2>
|
||||
{overview && (
|
||||
<div className="flex items-center gap-3 text-[13px]" style={{ color: 'var(--text-dim)' }}>
|
||||
<span>{overview.total_tasks} 个任务</span>
|
||||
<span className="w-[1px] h-3 bg-[var(--border)]" />
|
||||
<span>{overview.profile_count} 个 Profile</span>
|
||||
<span className="w-[1px] h-3 bg-[var(--border)]" />
|
||||
<span style={{ color: 'var(--blue)' }}>{overview.running_count} 个 RUNNING</span>
|
||||
{overview.blocked_count > 0 && (
|
||||
<>
|
||||
<span className="w-[1px] h-3 bg-[var(--border)]" />
|
||||
<span style={{ color: 'var(--orange)' }}>{overview.blocked_count} 个阻塞</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
value={workerFilter}
|
||||
onChange={(e) => setWorkerFilter(e.target.value)}
|
||||
className="text-[12px] px-2 py-1.5 rounded-[var(--radius-sm)] border border-[var(--border)] bg-transparent"
|
||||
style={{ color: 'var(--text)', maxWidth: 130 }}
|
||||
>
|
||||
<option value="">全部 Worker</option>
|
||||
{profiles.map((p) => (
|
||||
<option key={p} value={p}>{p}</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
value={boardFilter}
|
||||
onChange={(e) => setBoardFilter(e.target.value)}
|
||||
className="text-[12px] px-2 py-1.5 rounded-[var(--radius-sm)] border border-[var(--border)] bg-transparent"
|
||||
style={{ color: 'var(--text)', maxWidth: 130 }}
|
||||
>
|
||||
<option value="">全部看板</option>
|
||||
{boards.map((b) => (
|
||||
<option key={b} value={b}>{b}</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
value={anomalyFilter}
|
||||
onChange={(e) => setAnomalyFilter(e.target.value)}
|
||||
className="text-[12px] px-2 py-1.5 rounded-[var(--radius-sm)] border border-[var(--border)] bg-transparent"
|
||||
style={{ color: 'var(--text)', maxWidth: 140 }}
|
||||
>
|
||||
<option value="">全部状态</option>
|
||||
<option value="blocked_timeout">阻塞超时</option>
|
||||
<option value="unclaimed">长期无主</option>
|
||||
<option value="retry_failure">失败重试</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ===== Five-column Board ===== */}
|
||||
<div
|
||||
className="flex gap-3 flex-1 overflow-x-auto pb-4"
|
||||
style={{ minHeight: 0 }}
|
||||
>
|
||||
{COLUMN_ORDER.map((colKey) => {
|
||||
const colTasks = getColumnTasks(colKey)
|
||||
const meta = COLUMN_META[colKey]
|
||||
const isBlocked = colKey === 'blocked'
|
||||
|
||||
return (
|
||||
<div
|
||||
key={colKey}
|
||||
className="flex flex-col rounded-[var(--radius)] shrink-0"
|
||||
style={{
|
||||
minWidth: 240,
|
||||
width: 0,
|
||||
flex: '1 1 0',
|
||||
backgroundColor: meta.bg,
|
||||
border: isBlocked ? '1px solid var(--orange)' : '1px solid var(--border)',
|
||||
borderLeft: isBlocked ? '3px solid var(--orange)' : '1px solid var(--border)',
|
||||
}}
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={(e) => handleDrop(e, colKey)}
|
||||
>
|
||||
{/* Column Header */}
|
||||
<div
|
||||
className="sticky top-0 z-10 px-3 py-2.5 flex items-center justify-between"
|
||||
style={{
|
||||
backgroundColor: isBlocked ? 'rgba(255,109,0,0.04)' : 'transparent',
|
||||
borderBottom: '1px solid var(--border)',
|
||||
}}
|
||||
>
|
||||
<span style={{ color: meta.color, fontSize: 12, fontWeight: 700, letterSpacing: '0.5px' }}>
|
||||
{meta.label}
|
||||
</span>
|
||||
<span
|
||||
className="text-[11px] font-semibold px-2 py-0.5 rounded-[10px]"
|
||||
style={{
|
||||
backgroundColor: 'var(--bg-hover)',
|
||||
color: 'var(--text-dim)',
|
||||
}}
|
||||
>
|
||||
{colTasks.length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Column Content */}
|
||||
<div className="flex-1 overflow-y-auto p-2 space-y-2" style={{ minHeight: 120 }}>
|
||||
{colTasks.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-24 text-[12px]" style={{ color: 'var(--text-muted)' }}>
|
||||
暂无任务
|
||||
</div>
|
||||
) : (
|
||||
colTasks.map((t) => (
|
||||
<TaskCard
|
||||
key={t.id}
|
||||
task={t}
|
||||
onDragStart={handleDragStart}
|
||||
onCardClick={openDrawer}
|
||||
checked={checkedIds.has(t.id)}
|
||||
onCheckChange={handleCheckChange}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{/* ===== Detail Drawer ===== */}
|
||||
<Drawer
|
||||
open={drawerOpen}
|
||||
onClose={closeDrawer}
|
||||
width={480}
|
||||
title={
|
||||
drawerTask ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<Badge variant={
|
||||
drawerTask.column === 'running' ? 'success' :
|
||||
drawerTask.column === 'blocked' ? 'warning' :
|
||||
drawerTask.column === 'done' ? 'outline' : 'info'
|
||||
}>
|
||||
{COLUMN_META[drawerTask.column]?.label || drawerTask.column}
|
||||
</Badge>
|
||||
{drawerTask.title}
|
||||
</span>
|
||||
) : undefined
|
||||
}
|
||||
footer={
|
||||
drawerTask ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm">编辑</Button>
|
||||
{drawerTask.column !== 'blocked' && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
closeDrawer()
|
||||
setBlockReasonModal({ task: drawerTask, toColumn: 'blocked' })
|
||||
setBlockReason('')
|
||||
}}
|
||||
>
|
||||
标记阻塞
|
||||
</Button>
|
||||
)}
|
||||
{drawerTask.column !== 'done' && (
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={async () => {
|
||||
await doMove(drawerTask, 'done')
|
||||
closeDrawer()
|
||||
}}
|
||||
>
|
||||
标记完成
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="danger" size="sm" style={{ marginLeft: 'auto' }}>
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{drawerTask && (
|
||||
<div className="space-y-5 text-[13px]">
|
||||
{/* Meta Section */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<MetaItem label="看板" value={drawerTask.board} />
|
||||
<MetaItem label="Worker" value={drawerTask.worker_profile} />
|
||||
<MetaItem label="类型" value={drawerTask.worker_type} />
|
||||
<MetaItem
|
||||
label="优先级"
|
||||
value={
|
||||
<span style={{ color: priorityColor(drawerTask.priority), fontWeight: 600 }}>
|
||||
{priorityLabel(drawerTask.priority)}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
<MetaItem label="创建时间" value={relativeTime(drawerTask.created_at)} />
|
||||
<MetaItem label="更新时间" value={relativeTime(drawerTask.updated_at)} />
|
||||
<MetaItem
|
||||
label="Goal Mode"
|
||||
value={drawerTask.goal_mode ? '🎯 启用' : '未启用'}
|
||||
/>
|
||||
<MetaItem
|
||||
label="重试/失败"
|
||||
value={`${drawerTask.retries}/${drawerTask.failures}`}
|
||||
valueColor={drawerTask.failures > 0 ? 'var(--red)' : undefined}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Anomalies */}
|
||||
{drawerTask.anomalies.length > 0 && (
|
||||
<div>
|
||||
<SectionTitle>异常标记</SectionTitle>
|
||||
<div className="flex gap-2">
|
||||
{drawerTask.anomalies.map((a) => (
|
||||
<Badge key={a} variant={a === 'unclaimed' ? 'warning' : 'danger'}>
|
||||
{a === 'blocked_timeout' ? '阻塞超时' : a === 'unclaimed' ? '长期无主' : '失败重试'}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Block Reason */}
|
||||
{drawerTask.block_reason && (
|
||||
<div>
|
||||
<SectionTitle>阻塞原因</SectionTitle>
|
||||
<div
|
||||
className="p-3 rounded-[var(--radius-sm)]"
|
||||
style={{ backgroundColor: 'var(--bg-warn)', color: 'var(--orange)' }}
|
||||
>
|
||||
{drawerTask.block_reason}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Description */}
|
||||
<div>
|
||||
<SectionTitle>任务描述</SectionTitle>
|
||||
<div
|
||||
className="p-3 rounded-[var(--radius-sm)] leading-relaxed whitespace-pre-wrap"
|
||||
style={{ backgroundColor: 'var(--bg-card)', color: 'var(--text)', fontFamily: 'var(--font-mono)', fontSize: 12 }}
|
||||
>
|
||||
{drawerTask.description}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dependencies */}
|
||||
{(drawerTask.dependency_tasks && drawerTask.dependency_tasks.length > 0) && (
|
||||
<div>
|
||||
<SectionTitle>依赖任务</SectionTitle>
|
||||
<div className="space-y-1">
|
||||
{drawerTask.dependency_tasks.map((dep) => (
|
||||
<div
|
||||
key={dep.id}
|
||||
className="flex items-center gap-2 p-2 rounded-[var(--radius-sm)] cursor-pointer"
|
||||
style={{ backgroundColor: 'var(--bg-hover)' }}
|
||||
onClick={() => {
|
||||
const found = tasks.find(t => t.id === dep.id)
|
||||
if (found) { closeDrawer(); setTimeout(() => openDrawer(found), 300) }
|
||||
}}
|
||||
>
|
||||
<Badge variant="outline" className="!text-[10px]">{dep.column}</Badge>
|
||||
<span style={{ color: 'var(--text)' }}>{dep.title}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Dependents */}
|
||||
{(drawerTask.dependent_tasks && drawerTask.dependent_tasks.length > 0) && (
|
||||
<div>
|
||||
<SectionTitle>被依赖任务</SectionTitle>
|
||||
<div className="space-y-1">
|
||||
{drawerTask.dependent_tasks.map((dep) => (
|
||||
<div
|
||||
key={dep.id}
|
||||
className="flex items-center gap-2 p-2 rounded-[var(--radius-sm)] cursor-pointer"
|
||||
style={{ backgroundColor: 'var(--bg-hover)' }}
|
||||
onClick={() => {
|
||||
const found = tasks.find(t => t.id === dep.id)
|
||||
if (found) { closeDrawer(); setTimeout(() => openDrawer(found), 300) }
|
||||
}}
|
||||
>
|
||||
<Badge variant="outline" className="!text-[10px]">{dep.column}</Badge>
|
||||
<span style={{ color: 'var(--text)' }}>{dep.title}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Activity Timeline */}
|
||||
{drawerTask.activities && drawerTask.activities.length > 0 && (
|
||||
<div>
|
||||
<SectionTitle>活动</SectionTitle>
|
||||
<div className="space-y-2">
|
||||
{drawerTask.activities.map((act, i) => (
|
||||
<div key={i} className="flex items-start gap-3 text-[12px]">
|
||||
<div
|
||||
className="w-[6px] h-[6px] rounded-full mt-1.5 shrink-0"
|
||||
style={{ backgroundColor: i === 0 ? 'var(--honey)' : 'var(--text-muted)' }}
|
||||
/>
|
||||
<div>
|
||||
<span style={{ color: 'var(--text)' }}>{act.action}</span>
|
||||
<div style={{ color: 'var(--text-muted)' }}>
|
||||
{act.user} · {relativeTime(act.time)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Drawer>
|
||||
|
||||
{/* ===== Block Reason Modal ===== */}
|
||||
{blockReasonModal && (
|
||||
<div
|
||||
className="fixed inset-0 z-[9999] flex items-center justify-center"
|
||||
style={{ backgroundColor: 'rgba(0,0,0,0.6)' }}
|
||||
onClick={() => setBlockReasonModal(null)}
|
||||
>
|
||||
<div
|
||||
className="bg-[var(--bg-nav)] rounded-[8px] p-5 w-full mx-4"
|
||||
style={{ maxWidth: 400 }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<h3 className="text-[14px] font-semibold mb-3" style={{ color: 'var(--text)' }}>
|
||||
移动到 BLOCKED
|
||||
</h3>
|
||||
<p className="text-[12px] mb-3" style={{ color: 'var(--text-dim)' }}>
|
||||
请填写阻塞原因(至少 10 字)
|
||||
</p>
|
||||
<textarea
|
||||
value={blockReason}
|
||||
onChange={(e) => setBlockReason(e.target.value)}
|
||||
className="w-full p-2.5 rounded-[var(--radius-sm)] border text-[13px]"
|
||||
style={{
|
||||
borderColor: 'var(--border)',
|
||||
backgroundColor: 'var(--bg-card)',
|
||||
color: 'var(--text)',
|
||||
minHeight: 80,
|
||||
resize: 'vertical',
|
||||
}}
|
||||
placeholder="阻塞原因..."
|
||||
autoFocus
|
||||
/>
|
||||
<div className="flex items-center justify-end gap-2 mt-4">
|
||||
<Button variant="ghost" onClick={() => setBlockReasonModal(null)}>取消</Button>
|
||||
<Button variant="primary" onClick={confirmBlockReason}>确认移动</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ===== Done Confirm Modal ===== */}
|
||||
{confirmDone && (
|
||||
<div
|
||||
className="fixed inset-0 z-[9999] flex items-center justify-center"
|
||||
style={{ backgroundColor: 'rgba(0,0,0,0.6)' }}
|
||||
onClick={() => setConfirmDone(null)}
|
||||
>
|
||||
<div
|
||||
className="bg-[var(--bg-nav)] rounded-[8px] p-5 w-full mx-4"
|
||||
style={{ maxWidth: 400 }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<h3 className="text-[14px] font-semibold mb-3" style={{ color: 'var(--text)' }}>
|
||||
确认完成
|
||||
</h3>
|
||||
<p className="text-[13px]" style={{ color: 'var(--text-dim)' }}>
|
||||
确认将 <strong style={{ color: 'var(--text)' }}>{confirmDone.title}</strong> 移入 DONE 列?
|
||||
</p>
|
||||
<div className="flex items-center justify-end gap-2 mt-4">
|
||||
<Button variant="ghost" onClick={() => setConfirmDone(null)}>取消</Button>
|
||||
<Button variant="primary" onClick={confirmDoneMove}>确认</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ===== Batch Operations Bar ===== */}
|
||||
{checkedCount > 0 && (
|
||||
<div
|
||||
className="fixed bottom-0 left-1/2 -translate-x-1/2 z-[999] flex items-center gap-3 px-5 py-3 rounded-t-[var(--radius)] shadow-[var(--shadow-lg)]"
|
||||
style={{
|
||||
backgroundColor: 'var(--bg-elevated)',
|
||||
border: '1px solid var(--border)',
|
||||
borderBottom: 'none',
|
||||
minWidth: 400,
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<span className="text-[13px]" style={{ color: 'var(--text-dim)' }}>
|
||||
已选择 {checkedCount} 个任务
|
||||
</span>
|
||||
<div className="w-[1px] h-4 bg-[var(--border)]" />
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
loading={batchActing}
|
||||
onClick={() => batchMove('done')}
|
||||
>
|
||||
标记完成
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
loading={batchActing}
|
||||
onClick={batchBlocked}
|
||||
>
|
||||
标记阻塞
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={clearSelection}>
|
||||
清除选择
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ===== Sub-components ===== */
|
||||
|
||||
function MetaItem({ label, value, valueColor }: { label: string; value: React.ReactNode; valueColor?: string }) {
|
||||
return (
|
||||
<div>
|
||||
<div className="text-[11px] mb-0.5" style={{ color: 'var(--text-muted)' }}>{label}</div>
|
||||
<div style={{ color: valueColor || 'var(--text)', fontWeight: 500 }}>{value}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SectionTitle({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="text-[12px] font-semibold mb-2" style={{ color: 'var(--text-dim)', letterSpacing: '0.3px' }}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user