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 fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
from app.database import engine, Base
|
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
|
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(gateway.router, prefix="/api/v1")
|
||||||
app.include_router(health.router, prefix="/api/v1")
|
app.include_router(health.router, prefix="/api/v1")
|
||||||
app.include_router(cron.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")
|
app.include_router(keys.router, prefix="/api/v1")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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<KanbanResponse>('/kanban/tasks', { params })
|
||||||
|
|
||||||
|
export const getKanbanTaskDetail = (taskId: string) =>
|
||||||
|
client.get<KanbanTask>(`/kanban/tasks/${taskId}`)
|
||||||
|
|
||||||
|
export const moveKanbanTask = (taskId: string, column: string, blockReason?: string) =>
|
||||||
|
client.patch<MoveResponse>(`/kanban/tasks/${taskId}/move`, { column, block_reason: blockReason })
|
||||||
@@ -4,7 +4,7 @@ import { createPortal } from 'react-dom'
|
|||||||
export interface DrawerProps {
|
export interface DrawerProps {
|
||||||
open: boolean
|
open: boolean
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
title?: string
|
title?: ReactNode
|
||||||
children: ReactNode
|
children: ReactNode
|
||||||
footer?: ReactNode
|
footer?: ReactNode
|
||||||
width?: string | number
|
width?: string | number
|
||||||
|
|||||||
@@ -1,3 +1,858 @@
|
|||||||
export default function KanbanPage() {
|
import { useEffect, useState, useCallback, useRef } from 'react'
|
||||||
return <div><h2 style={{ color: 'var(--text)', fontSize: 18, fontWeight: 600 }}>看板追踪</h2><p style={{ color: 'var(--text-dim)', marginTop: 8 }}>Kanban 任务看板</p></div>
|
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<string, { label: string; color: string; bg: string; borderColor: string }> = {
|
||||||
|
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 (
|
||||||
|
<div
|
||||||
|
draggable
|
||||||
|
onDragStart={(e) => 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)'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="absolute top-2 left-2 opacity-0 group-hover:opacity-100 transition-opacity z-10"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={checked}
|
||||||
|
onChange={(e) => onCheckChange(task.id, e.target.checked)}
|
||||||
|
style={{ accentColor: 'var(--honey)', width: 14, height: 14, cursor: 'pointer' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="font-semibold leading-snug mb-1.5"
|
||||||
|
style={{
|
||||||
|
color: 'var(--text)',
|
||||||
|
fontSize: 13,
|
||||||
|
display: '-webkit-box',
|
||||||
|
WebkitLineClamp: 2,
|
||||||
|
WebkitBoxOrient: 'vertical',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{task.title}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 text-[11px]" style={{ color: 'var(--text-dim)' }}>
|
||||||
|
<span>{task.worker_profile}</span>
|
||||||
|
<span>{'\u{1F41D}'}</span>
|
||||||
|
{task.goal_mode && <span title="goal_mode">{'\u{1F3AF}'}</span>}
|
||||||
|
{task.priority === 0 && <span style={{ color: 'var(--red)', fontWeight: 600, fontSize: 10 }}>{'⚡'}P0</span>}
|
||||||
|
{(task.retries > 0 || task.failures > 0) && (
|
||||||
|
<span style={{ color: task.failures > 0 ? 'var(--red)' : 'var(--yellow)', fontSize: 10 }}>
|
||||||
|
{'↻'}{task.retries}/{task.failures}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-[10px] mt-1" style={{ color: 'var(--text-muted)' }}>
|
||||||
|
{relativeTime(task.created_at)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{task.column === 'blocked' && task.block_reason && (
|
||||||
|
<div
|
||||||
|
className="text-[11px] mt-1.5 truncate"
|
||||||
|
style={{ color: 'var(--orange)' }}
|
||||||
|
title={task.block_reason}
|
||||||
|
>
|
||||||
|
{'⏸'} {task.block_reason}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{task.anomalies.length > 0 && (
|
||||||
|
<div className="flex gap-1 mt-1.5 flex-wrap">
|
||||||
|
{task.anomalies.includes('blocked_timeout') && (
|
||||||
|
<Badge variant="danger" className="!text-[9px]">阻塞超时</Badge>
|
||||||
|
)}
|
||||||
|
{task.anomalies.includes('unclaimed') && (
|
||||||
|
<Badge variant="warning" className="!text-[9px]">长期无主</Badge>
|
||||||
|
)}
|
||||||
|
{task.anomalies.includes('retry_failure') && (
|
||||||
|
<Badge variant="danger" className="!text-[9px]">失败重试</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Kanban Page Component ===== */
|
||||||
|
|
||||||
|
export default function KanbanPage() {
|
||||||
|
const isDesktop = !isTouchDevice()
|
||||||
|
|
||||||
|
/* data */
|
||||||
|
const [tasks, setTasks] = useState<KanbanTask[]>([])
|
||||||
|
const [overview, setOverview] = useState<KanbanOverview | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
/* filters */
|
||||||
|
const [workerFilter, setWorkerFilter] = useState('')
|
||||||
|
const [boardFilter, setBoardFilter] = useState('')
|
||||||
|
const [anomalyFilter, setAnomalyFilter] = useState('')
|
||||||
|
|
||||||
|
/* drawer */
|
||||||
|
const [drawerTask, setDrawerTask] = useState<KanbanTask | null>(null)
|
||||||
|
const [drawerOpen, setDrawerOpen] = useState(false)
|
||||||
|
|
||||||
|
/* drag state */
|
||||||
|
const dragTask = useRef<KanbanTask | null>(null)
|
||||||
|
const [blockReasonModal, setBlockReasonModal] = useState<{ task: KanbanTask; toColumn: string } | null>(null)
|
||||||
|
const [blockReason, setBlockReason] = useState('')
|
||||||
|
const [confirmDone, setConfirmDone] = useState<KanbanTask | null>(null)
|
||||||
|
|
||||||
|
/* batch */
|
||||||
|
const [checkedIds, setCheckedIds] = useState<Set<string>>(new Set())
|
||||||
|
const [batchActing, setBatchActing] = useState(false)
|
||||||
|
|
||||||
|
/* filter options */
|
||||||
|
const [profiles, setProfiles] = useState<string[]>([])
|
||||||
|
const [boards, setBoards] = useState<string[]>([])
|
||||||
|
|
||||||
|
/* ---------- fetch ---------- */
|
||||||
|
|
||||||
|
const fetchTasks = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const params: Record<string, string> = {}
|
||||||
|
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 (
|
||||||
|
<div>
|
||||||
|
<h2 style={{ color: 'var(--text)', fontSize: 18, fontWeight: 600, marginBottom: 16 }}>
|
||||||
|
看板追踪
|
||||||
|
</h2>
|
||||||
|
<Skeleton variant="card" count={4} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- render: error ---------- */
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h2 style={{ color: 'var(--text)', fontSize: 18, fontWeight: 600, marginBottom: 16 }}>
|
||||||
|
看板追踪
|
||||||
|
</h2>
|
||||||
|
<div className="flex flex-col items-center justify-center py-12 gap-3" style={{ color: 'var(--text-dim)' }}>
|
||||||
|
<span style={{ fontSize: 32, color: 'var(--red)' }}>⚠</span>
|
||||||
|
<p>{error}</p>
|
||||||
|
<Button variant="outline" onClick={fetchTasks}>重试</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- render ---------- */
|
||||||
|
|
||||||
|
const checkedCount = checkedIds.size
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
|
{/* ===== Top Summary Bar ===== */}
|
||||||
|
<div className="flex items-center justify-between mb-4 flex-wrap gap-3">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<h2 style={{ color: 'var(--text)', fontSize: 18, fontWeight: 600, marginRight: 8 }}>
|
||||||
|
看板追踪
|
||||||
|
</h2>
|
||||||
|
{overview && (
|
||||||
|
<div className="flex items-center gap-3 text-[13px]" style={{ color: 'var(--text-dim)' }}>
|
||||||
|
<span>{overview.total_tasks} 个任务</span>
|
||||||
|
<span className="w-[1px] h-3 bg-[var(--border)]" />
|
||||||
|
<span>{overview.profile_count} 个 Profile</span>
|
||||||
|
<span className="w-[1px] h-3 bg-[var(--border)]" />
|
||||||
|
<span style={{ color: 'var(--blue)' }}>{overview.running_count} 个 RUNNING</span>
|
||||||
|
{overview.blocked_count > 0 && (
|
||||||
|
<>
|
||||||
|
<span className="w-[1px] h-3 bg-[var(--border)]" />
|
||||||
|
<span style={{ color: 'var(--orange)' }}>{overview.blocked_count} 个阻塞</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<select
|
||||||
|
value={workerFilter}
|
||||||
|
onChange={(e) => setWorkerFilter(e.target.value)}
|
||||||
|
className="text-[12px] px-2 py-1.5 rounded-[var(--radius-sm)] border border-[var(--border)] bg-transparent"
|
||||||
|
style={{ color: 'var(--text)', maxWidth: 130 }}
|
||||||
|
>
|
||||||
|
<option value="">全部 Worker</option>
|
||||||
|
{profiles.map((p) => (
|
||||||
|
<option key={p} value={p}>{p}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
value={boardFilter}
|
||||||
|
onChange={(e) => setBoardFilter(e.target.value)}
|
||||||
|
className="text-[12px] px-2 py-1.5 rounded-[var(--radius-sm)] border border-[var(--border)] bg-transparent"
|
||||||
|
style={{ color: 'var(--text)', maxWidth: 130 }}
|
||||||
|
>
|
||||||
|
<option value="">全部看板</option>
|
||||||
|
{boards.map((b) => (
|
||||||
|
<option key={b} value={b}>{b}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
value={anomalyFilter}
|
||||||
|
onChange={(e) => setAnomalyFilter(e.target.value)}
|
||||||
|
className="text-[12px] px-2 py-1.5 rounded-[var(--radius-sm)] border border-[var(--border)] bg-transparent"
|
||||||
|
style={{ color: 'var(--text)', maxWidth: 140 }}
|
||||||
|
>
|
||||||
|
<option value="">全部状态</option>
|
||||||
|
<option value="blocked_timeout">阻塞超时</option>
|
||||||
|
<option value="unclaimed">长期无主</option>
|
||||||
|
<option value="retry_failure">失败重试</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ===== Five-column Board ===== */}
|
||||||
|
<div
|
||||||
|
className="flex gap-3 flex-1 overflow-x-auto pb-4"
|
||||||
|
style={{ minHeight: 0 }}
|
||||||
|
>
|
||||||
|
{COLUMN_ORDER.map((colKey) => {
|
||||||
|
const colTasks = getColumnTasks(colKey)
|
||||||
|
const meta = COLUMN_META[colKey]
|
||||||
|
const isBlocked = colKey === 'blocked'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={colKey}
|
||||||
|
className="flex flex-col rounded-[var(--radius)] shrink-0"
|
||||||
|
style={{
|
||||||
|
minWidth: 240,
|
||||||
|
width: 0,
|
||||||
|
flex: '1 1 0',
|
||||||
|
backgroundColor: meta.bg,
|
||||||
|
border: isBlocked ? '1px solid var(--orange)' : '1px solid var(--border)',
|
||||||
|
borderLeft: isBlocked ? '3px solid var(--orange)' : '1px solid var(--border)',
|
||||||
|
}}
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
onDrop={(e) => handleDrop(e, colKey)}
|
||||||
|
>
|
||||||
|
{/* Column Header */}
|
||||||
|
<div
|
||||||
|
className="sticky top-0 z-10 px-3 py-2.5 flex items-center justify-between"
|
||||||
|
style={{
|
||||||
|
backgroundColor: isBlocked ? 'rgba(255,109,0,0.04)' : 'transparent',
|
||||||
|
borderBottom: '1px solid var(--border)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ color: meta.color, fontSize: 12, fontWeight: 700, letterSpacing: '0.5px' }}>
|
||||||
|
{meta.label}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className="text-[11px] font-semibold px-2 py-0.5 rounded-[10px]"
|
||||||
|
style={{
|
||||||
|
backgroundColor: 'var(--bg-hover)',
|
||||||
|
color: 'var(--text-dim)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{colTasks.length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Column Content */}
|
||||||
|
<div className="flex-1 overflow-y-auto p-2 space-y-2" style={{ minHeight: 120 }}>
|
||||||
|
{colTasks.length === 0 ? (
|
||||||
|
<div className="flex items-center justify-center h-24 text-[12px]" style={{ color: 'var(--text-muted)' }}>
|
||||||
|
暂无任务
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
colTasks.map((t) => (
|
||||||
|
<TaskCard
|
||||||
|
key={t.id}
|
||||||
|
task={t}
|
||||||
|
onDragStart={handleDragStart}
|
||||||
|
onCardClick={openDrawer}
|
||||||
|
checked={checkedIds.has(t.id)}
|
||||||
|
onCheckChange={handleCheckChange}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{/* ===== Detail Drawer ===== */}
|
||||||
|
<Drawer
|
||||||
|
open={drawerOpen}
|
||||||
|
onClose={closeDrawer}
|
||||||
|
width={480}
|
||||||
|
title={
|
||||||
|
drawerTask ? (
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<Badge variant={
|
||||||
|
drawerTask.column === 'running' ? 'success' :
|
||||||
|
drawerTask.column === 'blocked' ? 'warning' :
|
||||||
|
drawerTask.column === 'done' ? 'outline' : 'info'
|
||||||
|
}>
|
||||||
|
{COLUMN_META[drawerTask.column]?.label || drawerTask.column}
|
||||||
|
</Badge>
|
||||||
|
{drawerTask.title}
|
||||||
|
</span>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
footer={
|
||||||
|
drawerTask ? (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button variant="outline" size="sm">编辑</Button>
|
||||||
|
{drawerTask.column !== 'blocked' && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
closeDrawer()
|
||||||
|
setBlockReasonModal({ task: drawerTask, toColumn: 'blocked' })
|
||||||
|
setBlockReason('')
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
标记阻塞
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{drawerTask.column !== 'done' && (
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
|
onClick={async () => {
|
||||||
|
await doMove(drawerTask, 'done')
|
||||||
|
closeDrawer()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
标记完成
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button variant="danger" size="sm" style={{ marginLeft: 'auto' }}>
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{drawerTask && (
|
||||||
|
<div className="space-y-5 text-[13px]">
|
||||||
|
{/* Meta Section */}
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<MetaItem label="看板" value={drawerTask.board} />
|
||||||
|
<MetaItem label="Worker" value={drawerTask.worker_profile} />
|
||||||
|
<MetaItem label="类型" value={drawerTask.worker_type} />
|
||||||
|
<MetaItem
|
||||||
|
label="优先级"
|
||||||
|
value={
|
||||||
|
<span style={{ color: priorityColor(drawerTask.priority), fontWeight: 600 }}>
|
||||||
|
{priorityLabel(drawerTask.priority)}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<MetaItem label="创建时间" value={relativeTime(drawerTask.created_at)} />
|
||||||
|
<MetaItem label="更新时间" value={relativeTime(drawerTask.updated_at)} />
|
||||||
|
<MetaItem
|
||||||
|
label="Goal Mode"
|
||||||
|
value={drawerTask.goal_mode ? '🎯 启用' : '未启用'}
|
||||||
|
/>
|
||||||
|
<MetaItem
|
||||||
|
label="重试/失败"
|
||||||
|
value={`${drawerTask.retries}/${drawerTask.failures}`}
|
||||||
|
valueColor={drawerTask.failures > 0 ? 'var(--red)' : undefined}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Anomalies */}
|
||||||
|
{drawerTask.anomalies.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<SectionTitle>异常标记</SectionTitle>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{drawerTask.anomalies.map((a) => (
|
||||||
|
<Badge key={a} variant={a === 'unclaimed' ? 'warning' : 'danger'}>
|
||||||
|
{a === 'blocked_timeout' ? '阻塞超时' : a === 'unclaimed' ? '长期无主' : '失败重试'}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Block Reason */}
|
||||||
|
{drawerTask.block_reason && (
|
||||||
|
<div>
|
||||||
|
<SectionTitle>阻塞原因</SectionTitle>
|
||||||
|
<div
|
||||||
|
className="p-3 rounded-[var(--radius-sm)]"
|
||||||
|
style={{ backgroundColor: 'var(--bg-warn)', color: 'var(--orange)' }}
|
||||||
|
>
|
||||||
|
{drawerTask.block_reason}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
<div>
|
||||||
|
<SectionTitle>任务描述</SectionTitle>
|
||||||
|
<div
|
||||||
|
className="p-3 rounded-[var(--radius-sm)] leading-relaxed whitespace-pre-wrap"
|
||||||
|
style={{ backgroundColor: 'var(--bg-card)', color: 'var(--text)', fontFamily: 'var(--font-mono)', fontSize: 12 }}
|
||||||
|
>
|
||||||
|
{drawerTask.description}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Dependencies */}
|
||||||
|
{(drawerTask.dependency_tasks && drawerTask.dependency_tasks.length > 0) && (
|
||||||
|
<div>
|
||||||
|
<SectionTitle>依赖任务</SectionTitle>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{drawerTask.dependency_tasks.map((dep) => (
|
||||||
|
<div
|
||||||
|
key={dep.id}
|
||||||
|
className="flex items-center gap-2 p-2 rounded-[var(--radius-sm)] cursor-pointer"
|
||||||
|
style={{ backgroundColor: 'var(--bg-hover)' }}
|
||||||
|
onClick={() => {
|
||||||
|
const found = tasks.find(t => t.id === dep.id)
|
||||||
|
if (found) { closeDrawer(); setTimeout(() => openDrawer(found), 300) }
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Badge variant="outline" className="!text-[10px]">{dep.column}</Badge>
|
||||||
|
<span style={{ color: 'var(--text)' }}>{dep.title}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Dependents */}
|
||||||
|
{(drawerTask.dependent_tasks && drawerTask.dependent_tasks.length > 0) && (
|
||||||
|
<div>
|
||||||
|
<SectionTitle>被依赖任务</SectionTitle>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{drawerTask.dependent_tasks.map((dep) => (
|
||||||
|
<div
|
||||||
|
key={dep.id}
|
||||||
|
className="flex items-center gap-2 p-2 rounded-[var(--radius-sm)] cursor-pointer"
|
||||||
|
style={{ backgroundColor: 'var(--bg-hover)' }}
|
||||||
|
onClick={() => {
|
||||||
|
const found = tasks.find(t => t.id === dep.id)
|
||||||
|
if (found) { closeDrawer(); setTimeout(() => openDrawer(found), 300) }
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Badge variant="outline" className="!text-[10px]">{dep.column}</Badge>
|
||||||
|
<span style={{ color: 'var(--text)' }}>{dep.title}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Activity Timeline */}
|
||||||
|
{drawerTask.activities && drawerTask.activities.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<SectionTitle>活动</SectionTitle>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{drawerTask.activities.map((act, i) => (
|
||||||
|
<div key={i} className="flex items-start gap-3 text-[12px]">
|
||||||
|
<div
|
||||||
|
className="w-[6px] h-[6px] rounded-full mt-1.5 shrink-0"
|
||||||
|
style={{ backgroundColor: i === 0 ? 'var(--honey)' : 'var(--text-muted)' }}
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<span style={{ color: 'var(--text)' }}>{act.action}</span>
|
||||||
|
<div style={{ color: 'var(--text-muted)' }}>
|
||||||
|
{act.user} · {relativeTime(act.time)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Drawer>
|
||||||
|
|
||||||
|
{/* ===== Block Reason Modal ===== */}
|
||||||
|
{blockReasonModal && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-[9999] flex items-center justify-center"
|
||||||
|
style={{ backgroundColor: 'rgba(0,0,0,0.6)' }}
|
||||||
|
onClick={() => setBlockReasonModal(null)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="bg-[var(--bg-nav)] rounded-[8px] p-5 w-full mx-4"
|
||||||
|
style={{ maxWidth: 400 }}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<h3 className="text-[14px] font-semibold mb-3" style={{ color: 'var(--text)' }}>
|
||||||
|
移动到 BLOCKED
|
||||||
|
</h3>
|
||||||
|
<p className="text-[12px] mb-3" style={{ color: 'var(--text-dim)' }}>
|
||||||
|
请填写阻塞原因(至少 10 字)
|
||||||
|
</p>
|
||||||
|
<textarea
|
||||||
|
value={blockReason}
|
||||||
|
onChange={(e) => setBlockReason(e.target.value)}
|
||||||
|
className="w-full p-2.5 rounded-[var(--radius-sm)] border text-[13px]"
|
||||||
|
style={{
|
||||||
|
borderColor: 'var(--border)',
|
||||||
|
backgroundColor: 'var(--bg-card)',
|
||||||
|
color: 'var(--text)',
|
||||||
|
minHeight: 80,
|
||||||
|
resize: 'vertical',
|
||||||
|
}}
|
||||||
|
placeholder="阻塞原因..."
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<div className="flex items-center justify-end gap-2 mt-4">
|
||||||
|
<Button variant="ghost" onClick={() => setBlockReasonModal(null)}>取消</Button>
|
||||||
|
<Button variant="primary" onClick={confirmBlockReason}>确认移动</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ===== Done Confirm Modal ===== */}
|
||||||
|
{confirmDone && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-[9999] flex items-center justify-center"
|
||||||
|
style={{ backgroundColor: 'rgba(0,0,0,0.6)' }}
|
||||||
|
onClick={() => setConfirmDone(null)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="bg-[var(--bg-nav)] rounded-[8px] p-5 w-full mx-4"
|
||||||
|
style={{ maxWidth: 400 }}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<h3 className="text-[14px] font-semibold mb-3" style={{ color: 'var(--text)' }}>
|
||||||
|
确认完成
|
||||||
|
</h3>
|
||||||
|
<p className="text-[13px]" style={{ color: 'var(--text-dim)' }}>
|
||||||
|
确认将 <strong style={{ color: 'var(--text)' }}>{confirmDone.title}</strong> 移入 DONE 列?
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center justify-end gap-2 mt-4">
|
||||||
|
<Button variant="ghost" onClick={() => setConfirmDone(null)}>取消</Button>
|
||||||
|
<Button variant="primary" onClick={confirmDoneMove}>确认</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ===== Batch Operations Bar ===== */}
|
||||||
|
{checkedCount > 0 && (
|
||||||
|
<div
|
||||||
|
className="fixed bottom-0 left-1/2 -translate-x-1/2 z-[999] flex items-center gap-3 px-5 py-3 rounded-t-[var(--radius)] shadow-[var(--shadow-lg)]"
|
||||||
|
style={{
|
||||||
|
backgroundColor: 'var(--bg-elevated)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
borderBottom: 'none',
|
||||||
|
minWidth: 400,
|
||||||
|
justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="text-[13px]" style={{ color: 'var(--text-dim)' }}>
|
||||||
|
已选择 {checkedCount} 个任务
|
||||||
|
</span>
|
||||||
|
<div className="w-[1px] h-4 bg-[var(--border)]" />
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
|
loading={batchActing}
|
||||||
|
onClick={() => batchMove('done')}
|
||||||
|
>
|
||||||
|
标记完成
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
loading={batchActing}
|
||||||
|
onClick={batchBlocked}
|
||||||
|
>
|
||||||
|
标记阻塞
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="sm" onClick={clearSelection}>
|
||||||
|
清除选择
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Sub-components ===== */
|
||||||
|
|
||||||
|
function MetaItem({ label, value, valueColor }: { label: string; value: React.ReactNode; valueColor?: string }) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="text-[11px] mb-0.5" style={{ color: 'var(--text-muted)' }}>{label}</div>
|
||||||
|
<div style={{ color: valueColor || 'var(--text)', fontWeight: 500 }}>{value}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SectionTitle({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="text-[12px] font-semibold mb-2" style={{ color: 'var(--text-dim)', letterSpacing: '0.3px' }}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user