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:
@@ -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
@@ -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")
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user