Files
track/frontend/src/components/TaskTree/TaskFlowView.tsx
duxingchen 262e28b9f2 fix: 仓储任务节点修复(assignee=None + WAREHOUSE 类型) + 前端防御性渲染
- webhook 生成任务 assignee_id 置 None,不再填中文,避免前端解析报错跳过渲染
- task_type=WAREHOUSE,并同步 isMain 判断(后端+双端前端)使其画在中央主干道
- 前端对扫码入库/出库任务:不请求用户数据,直接显示 📦 + 'MOM 仓储系统'
2026-09-01 17:25:22 +08:00

349 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 流转树双模式可视化 — 焦点模式 + 全景模式
*/
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 className={`shrink-0 ${big ? "h-4 w-4" : "h-3 w-3"}`} viewBox="0 0 8 8"><polygon points="0,0 8,4 0,8" fill={color} /></svg>;
}
/** 微型左箭头 SVG */
function ArrowLeft({ color = "#9ca3af", big }: { color?: string; big?: boolean }) {
return <svg className={`shrink-0 ${big ? "h-4 w-4" : "h-3 w-3"}`} viewBox="0 0 8 8"><polygon points="8,0 0,4 8,8" fill={color} /></svg>;
}
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<TaskResponse>();
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<string, string>;
}) {
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 (
<div className={`relative shrink-0 rounded-lg border bg-white ${sz.card} ${active ? "border-blue-400 ring-1 ring-blue-100" : "border-gray-200 opacity-75"} ${legacy ? "border-orange-300 animate-pulse" : ""} ${task.is_rework ? "border-l-red-500 border-l-2" : ""}`}>
{/* 左上角:主线/分支标签 + 状态标签并排 */}
<div className={`${sz.metaRow} flex items-center gap-1`}>
<span className={`rounded px-1.5 py-px font-bold text-white ${main ? "bg-blue-500" : "bg-purple-500"}`}>{main ? "主线" : "分支"}</span>
<span className={`rounded-full ${sz.status} font-medium ${cfg.bg} ${cfg.text}`}>{cfg.label}</span>
</div>
<p className={`${sz.title} font-bold text-gray-800 truncate`}>{task.task_name}</p>
{/* 操作人(带用户图标)+ 在库转入人;仓储任务防御性渲染为系统节点 */}
<div className={`${sz.metaRow} flex items-center gap-1`}>
{(() => {
const isSystemTask = task.task_name?.includes("扫码") || task.task_type === "WAREHOUSE";
if (isSystemTask) {
// ★ 仓储系统任务:不请求用户数据,直接显示系统图标 + 固定名assignee_id 为 null 不报错)
return (
<span className={`${sz.assignee} text-gray-400 truncate inline-flex items-center gap-0.5`}>
<Package className="h-2.5 w-2.5 shrink-0" />MOM
</span>
);
}
if (isWarehouse && task.assignee_id) {
return (
<span className={`${sz.sub} text-emerald-600`}>📥 : {assigneeName || task.assignee_id}</span>
);
}
return (
<span className={`${sz.assignee} text-gray-400 truncate inline-flex items-center gap-0.5`}>
<User className="h-2.5 w-2.5 shrink-0" />{assigneeName || task.assignee_id || "—"}
</span>
);
})()}
</div>
{/* 单行时间(精确到分钟) */}
<p className={`${sz.time} text-gray-300`}>
{fmtTime(task.created_at)}
{task.completed_at ? `${fmtTime(task.completed_at)}` : " → 至今"}
</p>
{legacy && <p className={`${sz.sub} text-orange-500`}>: {assigneeName || (findParent(task)?.assignee_id) || "历史任务"}</p>}
{isNestedSpawn && <p className={`${sz.sub} text-purple-500`}>: {findParent(task)?.assignee_id || "—"}</p>}
{/* 操作按钮 */}
{active && isOwner && (
<div className={`${sz.btnRow} flex gap-1 border-t border-gray-100`}>
{task.status?.toUpperCase() === TASK_STATUS.PENDING && <button onClick={() => onAction({ task, action: "receive" })} className={`flex-1 rounded border border-blue-200 bg-blue-50 ${sz.btn} text-blue-600`}></button>}
{task.status?.toUpperCase() !== TASK_STATUS.PENDING && <button onClick={() => onAction({ task, action: "transfer" })} className={`flex-1 rounded border border-green-200 bg-green-50 ${sz.btn} text-green-600`}></button>}
<button onClick={() => onAction({ task, action: "reject" })} className={`flex-1 rounded border border-red-200 bg-red-50 ${sz.btn} text-red-500`}></button>
</div>
)}
{active && !isOwner && isManager && (
<div className={`${sz.btnRow} flex gap-1 border-t border-orange-100`}>
<button onClick={() => onAction({ task, action: "reject" })} className={`flex-1 rounded border border-orange-200 bg-orange-50 ${sz.btn} text-orange-600`}></button>
<button onClick={() => onAction({ task, action: "transfer" })} className={`flex-1 rounded border border-orange-200 bg-orange-50 ${sz.btn} text-orange-600`}></button>
</div>
)}
{/* 操作日志:全行蓝色横条,铺满卡片宽度(含 padding+border整条可点击 */}
{task.records && task.records.length > 0 && (
<div
onClick={(e) => { 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}) <span className="font-bold text-blue-700"></span>
</div>
)}
</div>
);
});
// ============================================================
// 主视图
// ============================================================
interface TaskFlowViewProps {
tasks: TaskResponse[]; onAction: (t: ModalTarget) => void;
currentUser?: { username?: string; role?: string } | null;
assigneeNames?: Record<string, string>;
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<TaskResponse | null>(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<string, TaskResponse[]> = {};
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'
? (<div className="flex items-center"><ArrowLeft color={isLegacy ? "#fdba74" : "#9ca3af"} big={big} /><div className={`${big ? "w-10 border-t-[3px]" : "w-6 border-t-2"} ${isLegacy ? "border-dashed border-orange-300" : "border-solid border-gray-400"}`} /></div>)
: (<div className="flex items-center"><div className={`${big ? "w-10 border-t-[3px]" : "w-6 border-t-2"} ${isLegacy ? "border-dashed border-orange-300" : "border-solid border-gray-400"}`} /><ArrowRight color={isLegacy ? "#fdba74" : "#9ca3af"} big={big} /></div>);
const card = <SlimCard task={node} active={active(node.status)} legacy={isLegacy} assigneeName={assigneeNames?.[node.assignee_id || ""]} onAction={onAction} currentUser={currentUser} onViewRecords={setRecordsTask} rootMainId={rootMainId} size={size} assigneeNames={assigneeNames} />;
const kidsContainer = kids.length > 0 ? (
<div className={`flex flex-col gap-2 ${side === 'left' ? 'items-end' : 'items-start'}`}>
{kids.map(k => renderBranch(k, side, isLegacy, rootMainId))}
</div>
) : null;
return (
<div key={node.id} className="flex flex-row items-center gap-1">
{side === 'left' && kidsContainer}
{side === 'left' && card}
{arrow}
{side === 'right' && card}
{side === 'right' && kidsContainer}
</div>
);
};
// 🚀 焦点模式 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 (
<div>
{/* 模式切换 */}
<div className="mb-3 flex justify-center">
<button onClick={() => setShowFullMap(!showFullMap)}
className={`rounded-full bg-gray-100 font-medium text-gray-600 hover:bg-gray-200 transition-colors ${size === "lg" ? "px-5 py-2 text-sm" : "px-4 py-1.5 text-xs"}`}>
{showFullMap ? "🔼 收起,仅看当前并发任务" : "👁️ 展开全景流转树 (查看包含已完工在内的完整历史)"}
</button>
</div>
{/* ─── 统一垂直时间线布局(焦点/全景共用) ─── */}
<div className="space-y-6">
{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 (
<div key={mainTask.id} className="relative">
{/* 中央垂直线:按行渲染;首行从卡片中间起,末行到卡片中间止,避免冒出/多截 */}
<div
className={`absolute left-1/2 -translate-x-1/2 z-0 bg-gray-200 ${size === "lg" ? "w-1" : "w-0.5"}`}
style={{
top: mainIdx === 0 ? "50%" : 0,
bottom: mainIdx === visibleMains.length - 1 ? "50%" : 0,
}}
/>
<div className="flex flex-row items-start w-full">
{/* 左翼 — 递归渲染,子子孙孙向外延伸 */}
<div className="flex-1 flex flex-col items-end justify-center gap-2 pr-2">
{leftDirect.map(c => renderBranch(c, 'left', lineStyle, mainTask.id))}
</div>
{/* 中央 */}
<div className="shrink-0 z-10 relative">
<SlimCard task={mainTask} active={active(mainTask.status)}
assigneeName={assigneeNames?.[mainTask.assignee_id || ""]} onAction={onAction} currentUser={currentUser} onViewRecords={setRecordsTask} size={size} assigneeNames={assigneeNames} />
{active(mainTask.status) && (
<div className={`absolute -top-1 -left-1 rounded-full bg-green-400 border-2 border-white ${size === "lg" ? "h-4 w-4" : "h-3 w-3"}`} />
)}
</div>
{/* 右翼 — 递归渲染,子子孙孙向外延伸 */}
<div className="flex-1 flex flex-col items-start justify-center gap-2 pl-2">
{rightDirect.map(c => renderBranch(c, 'right', lineStyle, mainTask.id))}
</div>
</div>
{visibleMains.indexOf(mainTask) < visibleMains.length - 1 && (
<div className="flex justify-center py-2">
<span className={`text-gray-300 ${size === "lg" ? "text-sm" : "text-[10px]"}`}></span>
</div>
)}
</div>
);
})}
{visibleMains.length === 0 && (
<p className={`text-center text-gray-400 py-8 ${size === "lg" ? "text-sm" : "text-xs"}`}>
{showFullMap ? "暂无流转记录" : "当前无活跃主线任务"}
</p>
)}
</div>
{/* 记录弹窗 */}
{recordsTask && (
<div className="fixed inset-0 z-[60] flex items-center justify-center">
<div className="absolute inset-0 bg-black/40 backdrop-blur-sm" onClick={() => setRecordsTask(null)} />
<div className={`relative z-10 mx-4 max-h-[80vh] w-full overflow-y-auto rounded-xl bg-white shadow-2xl ${size === "lg" ? "max-w-2xl p-6" : "max-w-md p-5"}`}>
<div className={`mb-3 flex items-center justify-between`}><h3 className={`font-bold ${size === "lg" ? "text-lg" : "text-sm"}`}> {recordsTask.task_name}</h3><button onClick={() => setRecordsTask(null)} className="rounded p-1 text-gray-400 hover:bg-gray-100"><X className="h-4 w-4" /></button></div>
{(recordsTask.records || []).length === 0 ? <p className="py-8 text-center text-sm text-gray-400"></p> :
<div className="space-y-2">{[...recordsTask.records!].reverse().map((r, i) => (
<div key={r.id} className="flex gap-2">
<div className={`mt-1.5 h-2 w-2 shrink-0 rounded-full ${i === 0 ? "bg-blue-500" : "bg-gray-300"}`} />
<div className="flex-1 rounded bg-gray-50 px-3 py-2"><p className={`text-gray-400 ${size === "lg" ? "text-xs" : "text-[10px]"}`}>{fmtTime(r.created_at)}</p>
{r.remark && <p className={`mt-0.5 text-gray-700 ${size === "lg" ? "text-sm" : "text-xs"}`}>{r.remark}</p>}
{(() => { const imgs = parseImages(r.images); if (!imgs.length) return null; return (
<Image.PreviewGroup>
<div className="mt-1 flex gap-1 flex-wrap">{imgs.map((img, j) => <Image key={j} src={imageUrl(img)} width={size === "lg" ? 96 : 56} height={size === "lg" ? 96 : 56} className="rounded border object-cover" style={{ objectFit: "cover" }} />)}</div>
</Image.PreviewGroup>
); })()}
</div>
</div>
))}</div>}
</div>
</div>
)}
</div>
);
});
export default TaskFlowView;