From 5c0352c7685926974691fb52f6566f3e6ba50e66 Mon Sep 17 00:00:00 2001 From: enzo Date: Wed, 22 Jul 2026 17:31:03 +0800 Subject: [PATCH] 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 --- backend/app/api/kanban.py | 385 ++++++++++++ backend/app/main.py | 3 +- frontend/src/api/kanban.ts | 55 ++ frontend/src/components/ui/Drawer.tsx | 2 +- frontend/src/pages/KanbanPage.tsx | 859 +++++++++++++++++++++++++- 5 files changed, 1300 insertions(+), 4 deletions(-) create mode 100644 backend/app/api/kanban.py create mode 100644 frontend/src/api/kanban.ts diff --git a/backend/app/api/kanban.py b/backend/app/api/kanban.py new file mode 100644 index 0000000..05c1c67 --- /dev/null +++ b/backend/app/api/kanban.py @@ -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="任务不存在") diff --git a/backend/app/main.py b/backend/app/main.py index 54051d9..d05d954 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -4,7 +4,7 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware 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 @@ -38,6 +38,7 @@ app.include_router(dashboard.router, prefix="/api/v1") app.include_router(gateway.router, prefix="/api/v1") app.include_router(health.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") diff --git a/frontend/src/api/kanban.ts b/frontend/src/api/kanban.ts new file mode 100644 index 0000000..a89ace6 --- /dev/null +++ b/frontend/src/api/kanban.ts @@ -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('/kanban/tasks', { params }) + +export const getKanbanTaskDetail = (taskId: string) => + client.get(`/kanban/tasks/${taskId}`) + +export const moveKanbanTask = (taskId: string, column: string, blockReason?: string) => + client.patch(`/kanban/tasks/${taskId}/move`, { column, block_reason: blockReason }) diff --git a/frontend/src/components/ui/Drawer.tsx b/frontend/src/components/ui/Drawer.tsx index 8e2b92f..f12cebf 100644 --- a/frontend/src/components/ui/Drawer.tsx +++ b/frontend/src/components/ui/Drawer.tsx @@ -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 diff --git a/frontend/src/pages/KanbanPage.tsx b/frontend/src/pages/KanbanPage.tsx index 4620a6b..02cd8a2 100644 --- a/frontend/src/pages/KanbanPage.tsx +++ b/frontend/src/pages/KanbanPage.tsx @@ -1,3 +1,858 @@ -export default function KanbanPage() { - return

看板追踪

Kanban 任务看板

+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 = { + 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 ( +
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)' + }} + > +
e.stopPropagation()} + > + onCheckChange(task.id, e.target.checked)} + style={{ accentColor: 'var(--honey)', width: 14, height: 14, cursor: 'pointer' }} + /> +
+ +
+ {task.title} +
+ +
+ {task.worker_profile} + {'\u{1F41D}'} + {task.goal_mode && {'\u{1F3AF}'}} + {task.priority === 0 && {'⚡'}P0} + {(task.retries > 0 || task.failures > 0) && ( + 0 ? 'var(--red)' : 'var(--yellow)', fontSize: 10 }}> + {'↻'}{task.retries}/{task.failures} + + )} +
+ +
+ {relativeTime(task.created_at)} +
+ + {task.column === 'blocked' && task.block_reason && ( +
+ {'⏸'} {task.block_reason} +
+ )} + + {task.anomalies.length > 0 && ( +
+ {task.anomalies.includes('blocked_timeout') && ( + 阻塞超时 + )} + {task.anomalies.includes('unclaimed') && ( + 长期无主 + )} + {task.anomalies.includes('retry_failure') && ( + 失败重试 + )} +
+ )} +
+ ) +} + +/* ===== Kanban Page Component ===== */ + +export default function KanbanPage() { + const isDesktop = !isTouchDevice() + + /* data */ + const [tasks, setTasks] = useState([]) + const [overview, setOverview] = useState(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + + /* filters */ + const [workerFilter, setWorkerFilter] = useState('') + const [boardFilter, setBoardFilter] = useState('') + const [anomalyFilter, setAnomalyFilter] = useState('') + + /* drawer */ + const [drawerTask, setDrawerTask] = useState(null) + const [drawerOpen, setDrawerOpen] = useState(false) + + /* drag state */ + const dragTask = useRef(null) + const [blockReasonModal, setBlockReasonModal] = useState<{ task: KanbanTask; toColumn: string } | null>(null) + const [blockReason, setBlockReason] = useState('') + const [confirmDone, setConfirmDone] = useState(null) + + /* batch */ + const [checkedIds, setCheckedIds] = useState>(new Set()) + const [batchActing, setBatchActing] = useState(false) + + /* filter options */ + const [profiles, setProfiles] = useState([]) + const [boards, setBoards] = useState([]) + + /* ---------- fetch ---------- */ + + const fetchTasks = useCallback(async () => { + try { + const params: Record = {} + 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 ( +
+

+ 看板追踪 +

+ +
+ ) + } + + /* ---------- render: error ---------- */ + + if (error) { + return ( +
+

+ 看板追踪 +

+
+ ⚠ +

{error}

+ +
+
+ ) + } + + /* ---------- render ---------- */ + + const checkedCount = checkedIds.size + + return ( +
+ {/* ===== Top Summary Bar ===== */} +
+
+

+ 看板追踪 +

+ {overview && ( +
+ {overview.total_tasks} 个任务 + + {overview.profile_count} 个 Profile + + {overview.running_count} 个 RUNNING + {overview.blocked_count > 0 && ( + <> + + {overview.blocked_count} 个阻塞 + + )} +
+ )} +
+ + {/* Filters */} +
+ + + +
+
+ + {/* ===== Five-column Board ===== */} +
+ {COLUMN_ORDER.map((colKey) => { + const colTasks = getColumnTasks(colKey) + const meta = COLUMN_META[colKey] + const isBlocked = colKey === 'blocked' + + return ( +
handleDrop(e, colKey)} + > + {/* Column Header */} +
+ + {meta.label} + + + {colTasks.length} + +
+ + {/* Column Content */} +
+ {colTasks.length === 0 ? ( +
+ 暂无任务 +
+ ) : ( + colTasks.map((t) => ( + + )) + )} +
+
+ ) + })} +
+ {/* ===== Detail Drawer ===== */} + + + {COLUMN_META[drawerTask.column]?.label || drawerTask.column} + + {drawerTask.title} + + ) : undefined + } + footer={ + drawerTask ? ( +
+ + {drawerTask.column !== 'blocked' && ( + + )} + {drawerTask.column !== 'done' && ( + + )} + +
+ ) : undefined + } + > + {drawerTask && ( +
+ {/* Meta Section */} +
+ + + + + {priorityLabel(drawerTask.priority)} + + } + /> + + + + 0 ? 'var(--red)' : undefined} + /> +
+ + {/* Anomalies */} + {drawerTask.anomalies.length > 0 && ( +
+ 异常标记 +
+ {drawerTask.anomalies.map((a) => ( + + {a === 'blocked_timeout' ? '阻塞超时' : a === 'unclaimed' ? '长期无主' : '失败重试'} + + ))} +
+
+ )} + + {/* Block Reason */} + {drawerTask.block_reason && ( +
+ 阻塞原因 +
+ {drawerTask.block_reason} +
+
+ )} + + {/* Description */} +
+ 任务描述 +
+ {drawerTask.description} +
+
+ + {/* Dependencies */} + {(drawerTask.dependency_tasks && drawerTask.dependency_tasks.length > 0) && ( +
+ 依赖任务 +
+ {drawerTask.dependency_tasks.map((dep) => ( +
{ + const found = tasks.find(t => t.id === dep.id) + if (found) { closeDrawer(); setTimeout(() => openDrawer(found), 300) } + }} + > + {dep.column} + {dep.title} +
+ ))} +
+
+ )} + + {/* Dependents */} + {(drawerTask.dependent_tasks && drawerTask.dependent_tasks.length > 0) && ( +
+ 被依赖任务 +
+ {drawerTask.dependent_tasks.map((dep) => ( +
{ + const found = tasks.find(t => t.id === dep.id) + if (found) { closeDrawer(); setTimeout(() => openDrawer(found), 300) } + }} + > + {dep.column} + {dep.title} +
+ ))} +
+
+ )} + + {/* Activity Timeline */} + {drawerTask.activities && drawerTask.activities.length > 0 && ( +
+ 活动 +
+ {drawerTask.activities.map((act, i) => ( +
+
+
+ {act.action} +
+ {act.user} · {relativeTime(act.time)} +
+
+
+ ))} +
+
+ )} +
+ )} + + + {/* ===== Block Reason Modal ===== */} + {blockReasonModal && ( +
setBlockReasonModal(null)} + > +
e.stopPropagation()} + > +

+ 移动到 BLOCKED +

+

+ 请填写阻塞原因(至少 10 字) +

+