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:
enzo
2026-07-22 17:31:03 +08:00
co-authored by Claude
parent 3d905b606a
commit 5c0352c768
5 changed files with 1300 additions and 4 deletions
+55
View File
@@ -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 })
+1 -1
View File
@@ -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
+857 -2
View File
@@ -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>
)
}