Files
track/frontend/src/components/TaskTree/TaskFlowView.tsx
duxingchen 7b8084a43d fix: 前端统一解析后端 detail 报错,杜绝 [object Object]
各处 catch 里直接 `toast(err?.response?.data?.detail ?? "xx失败")` 有坑:
请求体校验失败(422)时 detail 是数组,React 会把每个对象渲染成 [object Object]。

- utils/errorMessage.ts(新增): extractErrorMessage 统一解析三种形态 ——
  字符串 / FastAPI 校验错误数组 / 对象;数组取 msg 并剥掉 Pydantic 的
  "Value error, " 前缀、带上字段名、同字段去重后用「;」拼接;解析不出内容
  则回落到调用方给的兜底文案。行为与移动端 extractErrorDetail 对齐
- 19 处调用点全部替换:AdminTasksPage(4) / AdminProductsPage(5) /
  TaskTreeViewer(4) / AdminPrintConfigPage(2) / ScanPage / CreateProductDialog /
  ImageUploader / authApi
- TaskRejectPayload.images 与 taskApi.rejectTask 的注释同步为「选填,支持空数组」
- 新增 components/ui/ImageUploader.tsx(驳回弹窗选图上传组件),
  RejectModal 同步放开无图拦截:去掉 images 非空校验、提交按钮不再因
  无图禁用、标签由必填星号改为「(选填,最多 9 张)」,并移除因此失效的
  error 状态
- AdminTasksPage.tsx: 支持大屏下钻的 ?search / ?status / ?stage 预设参数,
  新增「售后流转中」状态 Tab(按 lifecycle_phase 判定)与全局重置;
  ?stage 只在跳入时生效一次,用户手动改条件即解除锁定
- 杂项清理: TaskFlowView 移除透传的 assigneeNames、TaskListCard/MyTasksPage
  移除未使用导入、QrScanner 关闭 verbose 日志
2026-09-15 10:57:56 +08:00

355 lines
20 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 { 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",
}: {
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";
}) {
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>
{/* 驳回/返工原因:原 REJECTED 取 reject_reason返工节点取含“驳回/返工”的备注 */}
{(() => {
const rj = task.reject_reason;
const rm = task.remark;
const txt = rj ? `❌ 驳回: ${rj}` : (rm && /驳回|返工/.test(rm) ? rm : null);
return txt ? <p className={`${sz.sub} text-red-500 whitespace-pre-wrap`}>{txt}</p> : null;
})()}
{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} />;
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} />
{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;