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>
386 lines
13 KiB
Python
386 lines
13 KiB
Python
"""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="任务不存在")
|