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="任务不存在")