Files
hive-manage-v3/backend/app/api/kanban.py
T
enzoandClaude 5c0352c768 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>
2026-07-22 17:31:03 +08:00

386 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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="任务不存在")