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
+385
View File
@@ -0,0 +1,385 @@
"""Kanban API — 五列任务看板 + 异常检测。"""
import copy
import logging
from datetime import datetime, timedelta, timezone
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Query
from app.middleware.auth import require_auth
from app.models.user import User
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/kanban", tags=["kanban"])
# ── Mock 数据(TODO: CLI integration) ──
NOW = datetime.now(timezone.utc)
def _ago(hours: int = 0, minutes: int = 0) -> str:
return (NOW - timedelta(hours=hours, minutes=minutes)).isoformat()
MOCK_TASKS: list[dict[str, Any]] = [
{
"id": "task-001",
"title": "生成 taiwan 早报简报",
"board": "简报生成",
"worker_profile": "taiwan",
"worker_type": "news-crawler",
"priority": 2,
"column": "running",
"created_at": _ago(hours=2),
"updated_at": _ago(minutes=15),
"goal_mode": False,
"retries": 0,
"failures": 0,
"description": "## 任务\n\n每日 07:00 生成 taiwan 地区早报简报\n\n### 步骤\n\n1. 爬取新闻源\n2. 摘要生成\n3. 推送飞书",
"block_reason": None,
"dependencies": [],
"dependents": ["task-004"],
"activities": [
{"time": _ago(minutes=15), "user": "system", "action": "开始执行"},
{"time": _ago(hours=2), "user": "hermes", "action": "加入队列"},
],
},
{
"id": "task-002",
"title": "guangxi 捐赠数据汇总",
"board": "数据采集",
"worker_profile": "guangxi",
"worker_type": "data-collector",
"priority": 1,
"column": "ready",
"created_at": _ago(hours=6),
"updated_at": _ago(hours=6),
"goal_mode": False,
"retries": 0,
"failures": 0,
"description": "## 任务\n\n汇总 guangxi 地区捐赠数据\n\n### 数据源\n\n- 内部 API\n- 公开报表",
"block_reason": None,
"dependencies": [],
"dependents": [],
"activities": [
{"time": _ago(hours=6), "user": "hermes", "action": "就绪"},
],
},
{
"id": "task-003",
"title": "运维日报推送 (ops-engineer)",
"board": "日报推送",
"worker_profile": "ops-engineer",
"worker_type": "reporter",
"priority": 3,
"column": "todo",
"created_at": _ago(hours=96),
"updated_at": _ago(hours=96),
"goal_mode": True,
"retries": 0,
"failures": 0,
"description": "## 任务\n\n每日 08:00 推送运维日报\n\n### 验收标准\n\n- [ ] 数据源正常\n- [ ] 推送成功",
"block_reason": None,
"dependencies": ["task-002"],
"dependents": [],
"activities": [
{"time": _ago(hours=96), "user": "hermes", "action": "创建任务"},
],
},
{
"id": "task-004",
"title": "hebo hindsight 归档",
"board": "数据归档",
"worker_profile": "hebo",
"worker_type": "archiver",
"priority": 2,
"column": "blocked",
"created_at": _ago(hours=30),
"updated_at": _ago(hours=30),
"goal_mode": False,
"retries": 2,
"failures": 3,
"description": "## 任务\n\n每日 21:00 hindsight 归档处理\n\n### 说明\n\n最近连续失败,上游 API 不稳定",
"block_reason": "上游 API 连续返回 502,需联系运维排查",
"dependencies": [],
"dependents": [],
"activities": [
{"time": _ago(hours=30), "user": "system", "action": "标记阻塞"},
{"time": _ago(hours=32), "user": "hermes", "action": "重试失败 (第3次)"},
],
},
{
"id": "task-005",
"title": "guangxi 周报生成",
"board": "简报生成",
"worker_profile": "guangxi",
"worker_type": "reporter",
"priority": 1,
"column": "todo",
"created_at": _ago(hours=12),
"updated_at": _ago(hours=12),
"goal_mode": False,
"retries": 0,
"failures": 0,
"description": "## 任务\n\n每周一生成 guangxi 周报",
"block_reason": None,
"dependencies": ["task-002"],
"dependents": [],
"activities": [
{"time": _ago(hours=12), "user": "hermes", "action": "创建任务"},
],
},
{
"id": "task-006",
"title": "taiwan 突发新闻推送",
"board": "简报生成",
"worker_profile": "taiwan",
"worker_type": "news-crawler",
"priority": 0,
"column": "running",
"created_at": _ago(hours=1),
"updated_at": _ago(minutes=5),
"goal_mode": False,
"retries": 1,
"failures": 0,
"description": "## 任务\n\n监测到突发新闻,立即推送飞书通知",
"block_reason": None,
"dependencies": [],
"dependents": [],
"activities": [
{"time": _ago(minutes=5), "user": "system", "action": "内容生成中"},
{"time": _ago(hours=1), "user": "hermes", "action": "触发执行"},
],
},
{
"id": "task-007",
"title": "飞书健康巡检",
"board": "系统维护",
"worker_profile": "ops-engineer",
"worker_type": "health-checker",
"priority": 0,
"column": "done",
"created_at": _ago(hours=72),
"updated_at": _ago(hours=48),
"goal_mode": True,
"retries": 0,
"failures": 0,
"description": "## 任务\n\n检查飞书连接健康状况\n\n### 验收标准\n\n- [x] API 连通性正常\n- [x] Webhook 可达",
"block_reason": None,
"dependencies": [],
"dependents": [],
"activities": [
{"time": _ago(hours=48), "user": "system", "action": "标记完成"},
{"time": _ago(hours=60), "user": "system", "action": "开始执行"},
{"time": _ago(hours=72), "user": "hermes", "action": "加入队列"},
],
},
{
"id": "task-008",
"title": "密钥轮换检查 (all profiles)",
"board": "安全审计",
"worker_profile": "ops-engineer",
"worker_type": "auditor",
"priority": 1,
"column": "blocked",
"created_at": _ago(hours=50),
"updated_at": _ago(hours=48),
"goal_mode": False,
"retries": 0,
"failures": 0,
"description": "## 任务\n\n检查所有 Profile 密钥有效期,轮换即将过期的密钥",
"block_reason": "等待安全审批,预计下周二完成",
"dependencies": [],
"dependents": [],
"activities": [
{"time": _ago(hours=48), "user": "manual", "action": "标记阻塞"},
{"time": _ago(hours=50), "user": "hermes", "action": "创建任务"},
],
},
{
"id": "task-009",
"title": "Gateway 配置同步",
"board": "系统维护",
"worker_profile": "taiwan",
"worker_type": "syncer",
"priority": 2,
"column": "ready",
"created_at": _ago(hours=4),
"updated_at": _ago(hours=4),
"goal_mode": False,
"retries": 0,
"failures": 1,
"description": "## 任务\n\n同步 taiwan Gateway 最新配置",
"block_reason": None,
"dependencies": [],
"dependents": [],
"activities": [
{"time": _ago(hours=4), "user": "hermes", "action": "就绪"},
],
},
{
"id": "task-010",
"title": "历史数据归档 (daily)",
"board": "数据归档",
"worker_profile": "hebo",
"worker_type": "archiver",
"priority": 2,
"column": "todo",
"created_at": _ago(hours=18),
"updated_at": _ago(hours=18),
"goal_mode": False,
"retries": 0,
"failures": 0,
"description": "## 任务\n\n每日历史数据归档",
"block_reason": None,
"dependencies": [],
"dependents": [],
"activities": [
{"time": _ago(hours=18), "user": "hermes", "action": "创建任务"},
],
},
]
# 用 createdAt 判断 unclaimed,为了测试效果把 task-003 设为 96h ago (>72h)
# task-004 failure=3 触发 retry_failure
# task-004/task-008 blocked 且 updated_at>24h 触发 blocked_timeout
def _detect_anomalies(task: dict) -> list[str]:
"""返回任务异常标记列表。"""
anomalies: list[str] = []
updated = datetime.fromisoformat(task["updated_at"])
created = datetime.fromisoformat(task["created_at"])
now = NOW
# blocked_timeout:BLOCKED 列超 24h → 标红
if task["column"] == "blocked" and (now - updated) > timedelta(hours=24):
anomalies.append("blocked_timeout")
# unclaimed:TODO 列超 3 天 → 标黄
if task["column"] == "todo" and (now - created) > timedelta(days=3):
anomalies.append("unclaimed")
# retry_failure:失败重试≥3 次 → 标红
if task.get("failures", 0) >= 3:
anomalies.append("retry_failure")
return anomalies
# ── API ──
@router.get("/tasks")
async def list_tasks(
worker: str = Query("", description="Worker profile 筛选"),
board: str = Query("", description="看板名筛选"),
anomaly: str = Query("", description="异常类型: blocked_timeout|unclaimed|retry_failure"),
_user: User = Depends(require_auth),
):
"""跨 Profile 五列任务聚合。"""
tasks = copy.deepcopy(MOCK_TASKS)
if worker:
tasks = [t for t in tasks if t["worker_profile"] == worker]
if board:
tasks = [t for t in tasks if t["board"] == board]
if anomaly:
tasks = [t for t in tasks if anomaly in _detect_anomalies(t)]
# 五列分组
columns = {"todo": [], "ready": [], "running": [], "blocked": [], "done": []}
for t in tasks:
anomalies = _detect_anomalies(t)
col = t["column"]
if col in columns:
columns[col].append({**t, "anomalies": anomalies})
# 每列按优先级排序
for col in columns:
columns[col].sort(key=lambda x: (x["priority"], x["created_at"]))
profiles = {t["worker_profile"] for t in tasks}
boards = {t["board"] for t in tasks}
return {
"todo": columns["todo"],
"ready": columns["ready"],
"running": columns["running"],
"blocked": columns["blocked"],
"done": columns["done"],
"overview": {
"total_tasks": len(tasks),
"profile_count": len(profiles),
"board_count": len(boards),
"running_count": len(columns["running"]),
"blocked_count": len(columns["blocked"]),
},
}
@router.get("/tasks/{task_id}")
async def get_task_detail(
task_id: str,
_user: User = Depends(require_auth),
):
"""任务详情。"""
for t in MOCK_TASKS:
if t["id"] == task_id:
detail = copy.deepcopy(t)
detail["anomalies"] = _detect_anomalies(t)
# 补全依赖/被依赖任务信息
dep_ids = detail.get("dependencies", [])
dep_tasks = [m for m in MOCK_TASKS if m["id"] in dep_ids]
detail["dependency_tasks"] = [
{"id": m["id"], "title": m["title"], "column": m["column"]}
for m in dep_tasks
]
dep_by_ids = detail.get("dependents", [])
dep_by_tasks = [m for m in MOCK_TASKS if m["id"] in dep_by_ids]
detail["dependent_tasks"] = [
{"id": m["id"], "title": m["title"], "column": m["column"]}
for m in dep_by_tasks
]
return detail
raise HTTPException(status_code=404, detail="任务不存在")
@router.patch("/tasks/{task_id}/move")
async def move_task(
task_id: str,
body: dict,
_user: User = Depends(require_auth),
):
"""拖拽移动任务。"""
column = body.get("column")
block_reason = body.get("block_reason")
valid_columns = {"todo", "ready", "running", "blocked", "done"}
if column not in valid_columns:
raise HTTPException(status_code=400, detail="无效的列名")
for t in MOCK_TASKS:
if t["id"] == task_id:
# 拖入 BLOCKED 需填写阻塞原因
if column == "blocked":
if not block_reason or len(block_reason.strip()) < 10:
raise HTTPException(
status_code=400,
detail="拖入 BLOCKED 列需填写阻塞原因(至少 10 字)",
)
t["block_reason"] = block_reason.strip()
else:
t["block_reason"] = None
t["column"] = column
t["updated_at"] = datetime.now(timezone.utc).isoformat()
t["activities"].insert(0, {
"time": t["updated_at"],
"user": "manual",
"action": f"移动到 {column}",
})
return {"success": True, "task": {**t, "anomalies": _detect_anomalies(t)}}
raise HTTPException(status_code=404, detail="任务不存在")
+2 -1
View File
@@ -4,7 +4,7 @@ from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from app.database import engine, Base from app.database import engine, Base
from app.api import auth, users, audit, dashboard, gateway, health, cron, keys from app.api import auth, users, audit, dashboard, gateway, health, cron, keys, kanban
from app.middleware.audit import AuditMiddleware from app.middleware.audit import AuditMiddleware
@@ -38,6 +38,7 @@ app.include_router(dashboard.router, prefix="/api/v1")
app.include_router(gateway.router, prefix="/api/v1") app.include_router(gateway.router, prefix="/api/v1")
app.include_router(health.router, prefix="/api/v1") app.include_router(health.router, prefix="/api/v1")
app.include_router(cron.router, prefix="/api/v1") app.include_router(cron.router, prefix="/api/v1")
app.include_router(kanban.router, prefix="/api/v1")
app.include_router(keys.router, prefix="/api/v1") app.include_router(keys.router, prefix="/api/v1")
+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 { export interface DrawerProps {
open: boolean open: boolean
onClose: () => void onClose: () => void
title?: string title?: ReactNode
children: ReactNode children: ReactNode
footer?: ReactNode footer?: ReactNode
width?: string | number width?: string | number
+857 -2
View File
@@ -1,3 +1,858 @@
export default function KanbanPage() { import { useEffect, useState, useCallback, useRef } from 'react'
return <div><h2 style={{ color: 'var(--text)', fontSize: 18, fontWeight: 600 }}>看板追踪</h2><p style={{ color: 'var(--text-dim)', marginTop: 8 }}>Kanban 任务看板</p></div> 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>
)
} }