/** * 流转树双模式可视化 — 焦点模式 + 全景模式 */ import { memo, useMemo, useState, useEffect } from "react"; import { Image } from "antd"; import { FileText, Package, User, 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())}`; } function isMain(t: TaskResponse) { return !t.parent_task_id || t.task_type === "TRANSFER" || t.task_type === "RECOVERY" || t.task_type === "WAREHOUSE"; } function active(s: string) { return s === "WIP" || s === "PENDING"; } /** 微型右箭头 SVG */ function ArrowRight({ color = "#9ca3af", big }: { color?: string; big?: boolean }) { return ; } /** 微型左箭头 SVG */ function ArrowLeft({ color = "#9ca3af", big }: { color?: string; big?: boolean }) { return ; } function parseImages(s: any): string[] { if (!s) return []; if (Array.isArray(s)) return s; // Pydantic 序列化后的数组 if (typeof s === "string") { try { return JSON.parse(s); } catch { return []; } } return []; } function imageUrl(u: string) { if (!u) return ""; if (u.startsWith("http")) return u; // 已是完整绝对地址(含跨域域名),直接用 const base = (import.meta.env.VITE_API_BASE_URL || "").replace(/\/+$/, ""); // 去掉末尾斜杠 const path = u.startsWith("/") ? u : "/" + u; // 后端图片固定返回 /api/v1/upload/files/... 这类 /api/ 开头的相对路径, // 需拼上 base 才能访问到后端;同时避免重复前缀: // - base 为纯域名(如 https://track_back.iris-rs.cn)→ 正常 base + path // - base 以 /api 或 /api/v1 结尾 → 先剥掉该段,否则会拼出 /api/api/ 或 /api/v1/api/v1 if (path.startsWith("/api/")) { const origin = base.replace(/\/api(\/v\d+)?$/, ""); return origin + path; } return base + path; } const ALL_TASKS = new Set(); function collectAll(tasks: TaskResponse[]) { tasks.forEach(t => { ALL_TASKS.add(t); if (t.child_tasks) collectAll(t.child_tasks); }); } function findParent(child: TaskResponse): TaskResponse | undefined { for (const t of ALL_TASKS) { if (t.id === child.parent_task_id) return t; } return undefined; } // ============================================================ // 尺寸映射表(sm=现状小卡片,lg=弹窗放大) // ============================================================ const SIZE_MAP = { sm: { card: "w-44 p-2.5 shadow-sm", badge: "-top-1.5 right-2 px-1.5 py-px text-[8px]", title: "mt-1 text-xs", metaRow: "mt-1", status: "px-1.5 py-px text-[8px]", assignee: "text-[9px]", time: "mt-1 text-[8px]", sub: "mt-1 text-[8px]", btnRow: "mt-1.5 pt-1.5", btn: "py-0.5 text-[8px]", record: "mt-1 px-1.5 py-0.5 text-[8px]", recordIcon: "h-2.5 w-2.5", }, lg: { card: "w-72 p-4 shadow-md", badge: "-top-2 right-3 px-2 py-0.5 text-xs", title: "mt-1.5 text-base", metaRow: "mt-2", status: "px-2 py-0.5 text-xs", assignee: "text-sm", time: "mt-2 text-xs", sub: "mt-1.5 text-xs", btnRow: "mt-2.5 pt-2.5", btn: "py-1.5 text-sm", record: "mt-2 px-2.5 py-1 text-xs", recordIcon: "h-4 w-4", }, } as const; // ============================================================ // 极简卡片 // ============================================================ const SlimCard = memo(function SlimCard({ task, assigneeName, active, legacy, onAction, currentUser, onViewRecords, rootMainId, size = "sm", assigneeNames, }: { task: TaskResponse; assigneeName?: string; active: boolean; legacy?: boolean; onAction: (t: ModalTarget) => void; currentUser?: { username?: string; role?: string } | null; onViewRecords?: (t: TaskResponse) => void; rootMainId?: string; size?: "sm" | "lg"; assigneeNames?: Record; }) { const cfg = getStatusConfig(task.status); const sz = SIZE_MAP[size]; const isOwner = !!(currentUser?.username && task.assignee_id === currentUser.username); const isManager = currentUser?.role === "SUPER_ADMIN" || currentUser?.role === "SUPERVISOR"; const main = isMain(task); const isNestedSpawn = !main && rootMainId && task.parent_task_id !== rootMainId && !!task.parent_task_id; // 📥 在库/入库任务:显示"谁转入在库"(创建该任务的人) const isWarehouse = !!( task.task_name?.includes("在库") || task.task_name?.includes("入库") || task.assignee_id === "virtual_warehouse" ); return (
{/* 左上角:主线/分支标签 + 状态标签并排 */}
{main ? "主线" : "分支"} {cfg.label}

{task.task_name}

{/* 操作人(带用户图标)+ 在库转入人;仓储任务防御性渲染为系统节点 */}
{(() => { const isSystemTask = task.task_name?.includes("扫码") || task.task_type === "WAREHOUSE"; if (isSystemTask) { // ★ 仓储系统任务:不请求用户数据,直接显示系统图标 + 固定名(assignee_id 为 null 不报错) return ( MOM 仓储系统 ); } if (isWarehouse && task.assignee_id) { return ( 📥 转入在库: {assigneeName || task.assignee_id} ); } return ( {assigneeName || task.assignee_id || "—"} ); })()}
{/* 单行时间(精确到分钟) */}

⏰ {fmtTime(task.created_at)} {task.completed_at ? ` → ${fmtTime(task.completed_at)}` : " → 至今"}

{legacy &&

源自: {assigneeName || (findParent(task)?.assignee_id) || "历史任务"}

} {isNestedSpawn &&

协助: {findParent(task)?.assignee_id || "—"}

} {/* 操作按钮 */} {active && isOwner && (
{task.status?.toUpperCase() === TASK_STATUS.PENDING && } {task.status?.toUpperCase() !== TASK_STATUS.PENDING && }
)} {active && !isOwner && isManager && (
)} {/* 操作日志:全行蓝色横条,铺满卡片宽度(含 padding+border),整条可点击 */} {task.records && task.records.length > 0 && (
{ e.stopPropagation(); onViewRecords?.(task); }} className={`${size === "lg" ? "-mx-[17px] -mb-[17px]" : "-mx-[11px] -mb-[11px]"} mt-3 py-1.5 bg-blue-50 text-blue-600 text-center text-sm font-medium cursor-pointer transition-colors hover:bg-blue-100 border-t border-blue-100 rounded-b-lg`} > 查看操作日志 ({task.records.length}条)
)}
); }); // ============================================================ // 主视图 // ============================================================ interface TaskFlowViewProps { tasks: TaskResponse[]; onAction: (t: ModalTarget) => void; currentUser?: { username?: string; role?: string } | null; assigneeNames?: Record; size?: "sm" | "lg"; } export const TaskFlowView = memo(function TaskFlowView({ tasks, onAction, currentUser, assigneeNames, size = "sm" }: TaskFlowViewProps) { const [showFullMap, setShowFullMap] = useState(false); const [recordsTask, setRecordsTask] = useState(null); // 🚀 记录弹窗打开时锁定背景滚动(防止滚动穿透),关闭时恢复 useEffect(() => { if (!recordsTask) return; const prev = document.body.style.overflow; document.body.style.overflow = "hidden"; return () => { document.body.style.overflow = prev; }; }, [recordsTask]); // 数据分类 — 🚀 仅根级主干作为垂直时间线节点,子节点通过 childMap 分支递归渲染 const { allMains, childMap } = useMemo(() => { ALL_TASKS.clear(); if (tasks.length) collectAll(tasks); const all = Array.from(ALL_TASKS); // 🚀 收集所有主线任务(全部进入中央垂直主轴) const mains: TaskResponse[] = []; for (const t of all) { if (isMain(t)) mains.push(t); } mains.sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime()); // 🚀 构建全局 childMap(按 parent_task_id 索引直接子节点,保留真实树结构) const childMap: Record = {}; for (const t of all) { const pid = t.parent_task_id || ''; if (!childMap[pid]) childMap[pid] = []; childMap[pid].push(t); } Object.values(childMap).forEach(arr => arr.sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime())); return { allMains: mains, childMap }; }, [tasks]); // 🚀 递归渲染分支节点 — 每个节点从自己的 childMap 获取直系子孙,保持树结构不断裂 const renderBranch = (node: TaskResponse, side: 'left' | 'right', isLegacy: boolean, rootMainId: string): React.JSX.Element => { const kids = childMap[node.id] || []; const big = size === "lg"; const arrow = side === 'left' ? (
) : (
); const card = ; const kidsContainer = kids.length > 0 ? (
{kids.map(k => renderBranch(k, side, isLegacy, rootMainId))}
) : null; return (
{side === 'left' && kidsContainer} {side === 'left' && card} {arrow} {side === 'right' && card} {side === 'right' && kidsContainer}
); }; // 🚀 焦点模式 vs 全景模式:共用同一套垂直时间线布局,仅数据过滤不同 const visibleMains = useMemo(() => { if (showFullMap) return allMains; // 焦点模式:保留自身活跃的主线,或含活跃分支(后代)的主线, // 避免「主线已完成、但并发协助分支仍在进行中」时整条分支被错误隐藏 const hasActiveDescendant = (node: TaskResponse): boolean => { const kids = childMap[node.id] || []; return kids.some(k => active(k.status) || hasActiveDescendant(k)); }; return allMains.filter(t => active(t.status) || hasActiveDescendant(t)); }, [allMains, childMap, showFullMap]); return (
{/* 模式切换 */}
{/* ─── 统一垂直时间线布局(焦点/全景共用) ─── */}
{visibleMains.map((mainTask, mainIdx) => { // 🔧 侧翼严格过滤:主线归主轴,仅 SPAWN 协助分支进入左右翼 const directChildren = (childMap[mainTask.id] || []).filter(c => !isMain(c)); const hasLegacyActive = directChildren.some(c => !isMain(c) && !active(mainTask.status)); const leftDirect = directChildren.filter((_, i) => i % 2 === 0); const rightDirect = directChildren.filter((_, i) => i % 2 === 1); const lineStyle = hasLegacyActive && !active(mainTask.status); return (
{/* 中央垂直线:按行渲染;首行从卡片中间起,末行到卡片中间止,避免冒出/多截 */}
{/* 左翼 — 递归渲染,子子孙孙向外延伸 */}
{leftDirect.map(c => renderBranch(c, 'left', lineStyle, mainTask.id))}
{/* 中央 */}
{active(mainTask.status) && (
)}
{/* 右翼 — 递归渲染,子子孙孙向外延伸 */}
{rightDirect.map(c => renderBranch(c, 'right', lineStyle, mainTask.id))}
{visibleMains.indexOf(mainTask) < visibleMains.length - 1 && (
)}
); })} {visibleMains.length === 0 && (

{showFullMap ? "暂无流转记录" : "当前无活跃主线任务"}

)}
{/* 记录弹窗 */} {recordsTask && (
setRecordsTask(null)} />

提交记录 — {recordsTask.task_name}

{(recordsTask.records || []).length === 0 ?

暂无记录

:
{[...recordsTask.records!].reverse().map((r, i) => (

{fmtTime(r.created_at)}

{r.remark &&

{r.remark}

} {(() => { const imgs = parseImages(r.images); if (!imgs.length) return null; return (
{imgs.map((img, j) => )}
); })()}
))}
}
)}
); }); export default TaskFlowView;