fix(frontend): TaskFlowView 根治双重渲染 — allMains 仅含根级节点

生产数据问题:16个任务中15个TRANSFER子节点全部挂在同一个root下,
isMain() 将它们同时放入 allMains 和 childMap,导致每个子节点渲染2次。

修复:
1. allMains 改为仅收集 parent_task_id IS NULL 的根级主干
   - TRANSFER 子节点仅通过 childMap → renderBranch 递归渲染
   - 彻底杜绝同一任务在时间线 + 分支中双重出现
2. 统一焦点/全景为同一套垂直时间线 JSX 布局
   - 删除旧焦点模式的独立 flex-row 排版
   - visibleMains useMemo 负责数据过滤 (active() 过滤)
3. 清理死代码: getRootMainId() / rootMainMap / srm / flatMap
This commit is contained in:
2026-08-11 15:37:04 +08:00
parent 30a48d90fc
commit 974f4b9d01

View File

@ -13,16 +13,6 @@ 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"; }
/** 寻根算法:从任意任务向上攀爬,找到最近的视觉主干节点 ID */
function getRootMainId(t: TaskResponse, flatMap: Record<string, TaskResponse>): string {
let curr: TaskResponse = t;
while (curr.parent_task_id && flatMap[curr.parent_task_id]) {
const p = flatMap[curr.parent_task_id];
if (isMain(p)) return p.id;
curr = p;
}
return curr.parent_task_id || curr.id;
}
function active(s: string) { return s === "WIP" || s === "PENDING"; }
/** 微型右箭头 SVG */
function ArrowRight({ color = "#9ca3af" }: { color?: string }) {
@ -108,34 +98,16 @@ export const TaskFlowView = memo(function TaskFlowView({ tasks, onAction, curren
const [showFullMap, setShowFullMap] = useState(false);
const [recordsTask, setRecordsTask] = useState<TaskResponse | null>(null);
// 数据分类 — 🚀 虚拟扁平化:三层+嵌套协助统一挂在视觉主干下
const { activeMain, historicalMain, childMap, rootMainMap } = useMemo(() => {
// 数据分类 — 🚀 仅根级主干作为垂直时间线节点,子节点通过 childMap 分支递归渲染
const { allMains, childMap } = useMemo(() => {
ALL_TASKS.clear(); if (tasks.length) collectAll(tasks);
const all = Array.from(ALL_TASKS);
// 拍平映射
const flatMap: Record<string, TaskResponse> = {};
for (const t of all) flatMap[t.id] = t;
// 计算每个 sub 任务的视觉根主干 ID
const rootMap: Record<string, string> = {};
const srm: Record<string, TaskResponse[]> = {};
const am: TaskResponse[] = []; const hm: TaskResponse[] = [];
// 🔧 仅收集根级主干节点parent_task_id IS NULL杜绝 TRANSFER 子节点双重渲染
const mains: TaskResponse[] = [];
for (const t of all) {
if (isMain(t)) {
if (active(t.status)) am.push(t); else hm.push(t);
srm[t.id] = [];
}
if (!t.parent_task_id && isMain(t)) mains.push(t);
}
// 非主线任务:按根主干分组
for (const t of all) {
if (isMain(t)) continue;
const rootId = getRootMainId(t, flatMap);
rootMap[t.id] = rootId;
if (!srm[rootId]) srm[rootId] = [];
srm[rootId].push(t);
}
// 排序
am.sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime());
hm.sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime());
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) {
@ -144,11 +116,9 @@ export const TaskFlowView = memo(function TaskFlowView({ tasks, onAction, curren
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 { activeMain: am, historicalMain: hm, childMap, rootMainMap: rootMap };
return { allMains: mains, childMap };
}, [tasks]);
const allMains = [...historicalMain, ...activeMain];
// 🚀 递归渲染分支节点 — 每个节点从自己的 childMap 获取直系子孙,保持树结构不断裂
const renderBranch = (node: TaskResponse, side: 'left' | 'right', isLegacy: boolean, rootMainId: string): JSX.Element => {
const kids = childMap[node.id] || [];
@ -172,6 +142,13 @@ export const TaskFlowView = memo(function TaskFlowView({ tasks, onAction, curren
);
};
// 🚀 焦点模式 vs 全景模式:共用同一套垂直时间线布局,仅数据过滤不同
const visibleMains = useMemo(() => {
if (showFullMap) return allMains;
// 焦点模式:过滤掉已完成/已入库的历史主线,保留活跃主线 + 所有协助分支
return allMains.filter(t => active(t.status));
}, [allMains, showFullMap]);
return (
<div>
{/* 模式切换 */}
@ -182,78 +159,51 @@ export const TaskFlowView = memo(function TaskFlowView({ tasks, onAction, curren
</button>
</div>
{/* ─── 焦点模式 ─── */}
{!showFullMap && (
<div className="flex flex-col items-center gap-4">
{activeMain.map(mainTask => {
const directChildren = childMap[mainTask.id] || [];
const leftDirect = directChildren.filter((_, i) => i % 2 === 0);
const rightDirect = directChildren.filter((_, i) => i % 2 === 1);
const isLegacy = !active(mainTask.status);
return (
<div key={mainTask.id} className="flex flex-row items-start w-full">
{/* ─── 统一垂直时间线布局(焦点/全景共用) ─── */}
<div className="space-y-6">
{visibleMains.map(mainTask => {
const directChildren = childMap[mainTask.id] || [];
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 top-0 bottom-0 w-0.5 bg-gray-200 -translate-x-1/2 z-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(t => renderBranch(t, 'left', isLegacy, mainTask.id))}
{leftDirect.map(c => renderBranch(c, 'left', lineStyle, mainTask.id))}
</div>
{/* 中央主干 */}
<div className="shrink-0 z-10">
<SlimCard task={mainTask} active assigneeName={assigneeNames?.[mainTask.assignee_id || ""]} onAction={onAction} currentUser={currentUser} onViewRecords={setRecordsTask} />
{/* 中央 */}
<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} />
{active(mainTask.status) && (
<div className="absolute -top-1 -left-1 h-3 w-3 rounded-full bg-green-400 border-2 border-white" />
)}
</div>
{/* 右翼 — 递归渲染,子子孙孙向外延伸 */}
<div className="flex-1 flex flex-col items-start justify-center gap-2 pl-2">
{rightDirect.map(t => renderBranch(t, 'right', isLegacy, mainTask.id))}
{rightDirect.map(c => renderBranch(c, 'right', lineStyle, mainTask.id))}
</div>
</div>
);
})}
{activeMain.length === 0 && <p className="text-xs text-gray-400 py-4">线</p>}
</div>
)}
{/* ─── 全景模式 ─── */}
{showFullMap && (
<div className="space-y-6">
{allMains.map(mainTask => {
const directChildren = childMap[mainTask.id] || [];
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 top-0 bottom-0 w-0.5 bg-gray-200 -translate-x-1/2 z-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} />
{active(mainTask.status) && (
<div className="absolute -top-1 -left-1 h-3 w-3 rounded-full bg-green-400 border-2 border-white" />
)}
</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>
{visibleMains.indexOf(mainTask) < visibleMains.length - 1 && (
<div className="flex justify-center py-2">
<span className="text-[10px] text-gray-300"></span>
</div>
{allMains.indexOf(mainTask) < allMains.length - 1 && (
<div className="flex justify-center py-2">
<span className="text-[10px] text-gray-300"></span>
</div>
)}
</div>
);
})}
{allMains.length === 0 && <p className="text-center text-xs text-gray-400 py-8"></p>}
</div>
)}
)}
</div>
);
})}
{visibleMains.length === 0 && (
<p className="text-center text-xs text-gray-400 py-8">
{showFullMap ? "暂无流转记录" : "当前无活跃主线任务"}
</p>
)}
</div>
{/* 记录弹窗 */}
{recordsTask && (