/** * 任务流转树 — 递归树形渲染,与移动端 TaskSwipeCards 核心逻辑对齐 * * 渲染规则: * - 顶层任务 (!parent_task_id) = 主分支(蓝色) * - TRANSFER/RECOVERY 子任务 = 主干下一环(同缩进,蓝色) * - SPAWN 子任务 = 分支(缩进 + 紫色 + 左侧连接线) */ import { memo, useState } from "react"; import { GitBranch, AlertTriangle, Clock, CheckCircle, Flag, FileText, X, } from "lucide-react"; import type { TaskResponse } from "../../types/api"; import { TASK_STATUS } from "../../types/api"; import { getStatusConfig } from "../../constants/task"; import type { ModalTarget } from "./TaskTreeViewer"; // ============================================================ // 工具函数 // ============================================================ function fmtTime(d: string | null) { if (!d) return ""; const dt = new Date(d); const pad = (n: number) => String(n).padStart(2, "0"); return `${dt.getFullYear()}-${pad(dt.getMonth() + 1)}-${pad(dt.getDate())} ${pad(dt.getHours())}:${pad(dt.getMinutes())}`; } /** 主线判定:与移动端 branchLabelMap/isMain 完全一致 */ function isMainTask(t: TaskResponse): boolean { return !t.parent_task_id || t.task_type === "TRANSFER" || t.task_type === "RECOVERY" || (!t.task_type && !!t.parent_task_id); } function parseImages(s: string | null | undefined): string[] { if (!s) return []; try { return JSON.parse(s); } catch { return []; } } function imageUrl(url: string) { if (!url) return ""; if (url.startsWith("http")) return url; return import.meta.env.VITE_API_BASE_URL + (url.startsWith("/") ? url : "/" + url); } /** 排序:主线优先,再按时间 */ function sortTasks(tasks: TaskResponse[]): TaskResponse[] { return [...tasks].sort((a, b) => { const aMain = isMainTask(a) ? -1 : 1; const bMain = isMainTask(b) ? -1 : 1; if (aMain !== bMain) return aMain - bMain; return new Date(a.created_at).getTime() - new Date(b.created_at).getTime(); }); } // ============================================================ // 停留耗时 // ============================================================ function calcDwell(receivedAt: string | null, completedAt: string | null, status: string): { text: string; highlight: boolean } | null { if (!receivedAt) return null; const start = new Date(receivedAt).getTime(); const end = completedAt ? new Date(completedAt).getTime() : Date.now(); const diffMs = end - start; if (diffMs < 0) return null; const m = Math.floor(diffMs / 60000); if (m < 1) return { text: "< 1分钟", highlight: status === "WIP" }; if (m < 60) return { text: `${m}分钟`, highlight: status === "WIP" }; const h = Math.floor(m / 60); const rm = m % 60; if (h < 24) return { text: `${h}小时${rm > 0 ? rm + "分钟" : ""}`, highlight: status === "WIP" }; const d = Math.floor(h / 24); const rh = h % 24; return { text: `${d}天${rh > 0 ? rh + "小时" : ""}`, highlight: status === "WIP" }; } // ============================================================ // 单张任务卡片 // ============================================================ const TaskCard = memo(function TaskCard({ task, isActive, onAction, currentUser, assigneeName, isBranch, onViewRecords, }: { task: TaskResponse; isActive: boolean; onAction: (t: ModalTarget) => void; currentUser?: { username?: string; role?: string } | null; assigneeName?: string; isBranch?: boolean; onViewRecords?: (t: TaskResponse) => void; }) { const cfg = getStatusConfig(task.status); const dwell = calcDwell(task.received_at, task.completed_at, task.status); const isCompleted = task.status?.toUpperCase() === "COMPLETED"; const isArchived = task.status?.toUpperCase() === "ARCHIVED"; const isCanceled = task.status?.toUpperCase() === "CANCELED"; const main = isMainTask(task); const isOwner = !!(currentUser?.username && task.assignee_id === currentUser.username); const isManager = currentUser?.role === "SUPER_ADMIN" || currentUser?.role === "SUPERVISOR"; return (
{/* 角标:主分支(蓝) / 分支(紫) */}
{main ? "主分支" : "分支"}
{/* 返工 / 入库 */}
{task.is_rework && 返工} {task.status === "ARCHIVED" && 📦 入库}
{/* 类型标签 + 状态 */}
{main ? "主分支" : "分支"} {task.child_tasks.length > 1 && ( 裂变×{task.child_tasks.length} )} {cfg.label}

{task.task_name}

{/* 负责人 */}
👤 负责人 {assigneeName || task.assignee_id || "未分配"}
{/* 备注 */} {task.remark && (

📌 备注

{task.remark}

)} {/* 提交记录 */} {task.records && task.records.length > 0 && (
{ e.stopPropagation(); onViewRecords?.(task); }} className="mt-2 flex cursor-pointer items-center gap-1.5 rounded-lg border border-blue-200 bg-gradient-to-r from-blue-50 to-indigo-50 px-2.5 py-2 transition-colors hover:from-blue-100 hover:to-indigo-100"> 共 {task.records.length} 条 查看 ›
)} {/* 耗时 */} {dwell && (

{dwell.highlight ? ⏳ 停留: {dwell.text} : 耗时: {dwell.text}}

)} {task.reject_reason &&

驳回原因: {task.reject_reason}

} {/* 状态标记 */}
{task.received_at && 已接收} {task.completed_at && 已完工}

创建: {fmtTime(task.created_at)}

{task.received_at &&

接收: {fmtTime(task.received_at)}

} {task.completed_at &&

完工: {fmtTime(task.completed_at)}

}
{/* 权限按钮 — 严格互斥 */} {isActive && isOwner && (
{task.status?.toUpperCase() === TASK_STATUS.PENDING && (<> )} {task.status?.toUpperCase() === TASK_STATUS.WIP && (<> )}
)} {isActive && !isOwner && isManager && (

⚠ 运维干预

)} {isActive && !isOwner && !isManager && (

非当前任务责任人

)}
); }); // ============================================================ // 记录弹窗 // ============================================================ const RecordsModal = memo(function RecordsModal({ open, task, onClose, }: { open: boolean; task: TaskResponse | null; onClose: () => void }) { if (!open || !task) return null; const records = task.records || []; return (

提交记录

{task.task_name}

{records.length === 0 ? (
📭

暂无历史记录

) : (
{[...records].reverse().map((rec, i) => (
{i < records.length - 1 &&
}

{fmtTime(rec.created_at)}

{rec.note &&

{rec.note}

} {rec.remark &&

{rec.remark}

} {(() => { const imgs = parseImages((rec as any).images); if (!imgs.length) return null; return
{imgs.map((img, j) => window.open(imageUrl(img), "_blank")} />)}
; })()}
))}
)}
); }); // ============================================================ // 🚀 递归任务节点 — 替代 flattenLevels // ============================================================ interface CommonProps { onAction: (t: ModalTarget) => void; currentUser?: { username?: string; role?: string } | null; assigneeNames?: Record; onViewRecords: (t: TaskResponse) => void; } const MAX_DEPTH = 10; // 🔧 防爆栈:递归深度上限 const TaskNode = memo(function TaskNode({ task, isBranch, common, depth = 0, }: { task: TaskResponse; isBranch: boolean; common: CommonProps; depth?: number; }) { // 🔧 防循环引用/脏数据导致死循环 if (depth > MAX_DEPTH) { return (

⚠ 树深度超限

请检查数据完整性

); } const isActive = task.status?.toUpperCase() === TASK_STATUS.PENDING || task.status?.toUpperCase() === TASK_STATUS.WIP; if (!task.child_tasks || task.child_tasks.length === 0) { return ( ); } // 分子任务:TRANSFER = 主干(同缩进),SPAWN = 分支(缩进) const sorted = sortTasks(task.child_tasks); const mainChildren = sorted.filter(c => isMainTask(c)); const spawnChildren = sorted.filter(c => !isMainTask(c)); return (
{/* 主干连接线 */} {mainChildren.length > 0 && (
)} {/* 当前节点卡片 */} {/* TRANSFER 子任务:主干下一环,同缩进,带竖线连接 */} {mainChildren.length > 0 && (
{/* 竖线 */}
{mainChildren.map((child) => (
{/* 横线连接 */}
))}
)} {/* SPAWN 子任务:分支,缩进 + 紫色左边线 */} {spawnChildren.length > 0 && (
{spawnChildren.map((child, idx) => (
0 ? "mt-2" : ""}>
))}
)}
); }); // ============================================================ // 主视图 // ============================================================ interface TaskFlowViewProps { tasks: TaskResponse[]; onAction: (target: ModalTarget) => void; currentUser?: { username?: string; role?: string } | null; assigneeNames?: Record; } export const TaskFlowView = memo(function TaskFlowView({ tasks, onAction, currentUser, assigneeNames }: TaskFlowViewProps) { const [recordsTask, setRecordsTask] = useState(null); if (!tasks || tasks.length === 0) return null; const common: CommonProps = { onAction, currentUser, assigneeNames, onViewRecords: setRecordsTask }; return (
流转树
{sortTasks(tasks).map((task) => ( ))}
setRecordsTask(null)} />
); }); export default TaskFlowView;