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")
|
||||
|
||||
|
||||
|
||||
@@ -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 {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
title?: string
|
||||
title?: ReactNode
|
||||
children: ReactNode
|
||||
footer?: ReactNode
|
||||
width?: string | number
|
||||
|
||||
@@ -1,3 +1,858 @@
|
||||
export default function KanbanPage() {
|
||||
return <div><h2 style={{ color: 'var(--text)', fontSize: 18, fontWeight: 600 }}>看板追踪</h2><p style={{ color: 'var(--text-dim)', marginTop: 8 }}>Kanban 任务看板</p></div>
|
||||
import { useEffect, useState, useCallback, useRef } from 'react'
|
||||
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