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 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"; } 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"; } function active(s: string) { return s === "WIP" || s === "PENDING"; }
/** 微型右箭头 SVG */ /** 微型右箭头 SVG */
function ArrowRight({ color = "#9ca3af" }: { color?: string }) { 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 [showFullMap, setShowFullMap] = useState(false);
const [recordsTask, setRecordsTask] = useState<TaskResponse | null>(null); const [recordsTask, setRecordsTask] = useState<TaskResponse | null>(null);
// 数据分类 — 🚀 虚拟扁平化:三层+嵌套协助统一挂在视觉主干下 // 数据分类 — 🚀 仅根级主干作为垂直时间线节点,子节点通过 childMap 分支递归渲染
const { activeMain, historicalMain, childMap, rootMainMap } = useMemo(() => { const { allMains, childMap } = useMemo(() => {
ALL_TASKS.clear(); if (tasks.length) collectAll(tasks); ALL_TASKS.clear(); if (tasks.length) collectAll(tasks);
const all = Array.from(ALL_TASKS); const all = Array.from(ALL_TASKS);
// 拍平映射 // 🔧 仅收集根级主干节点(parent_task_id IS NULL),杜绝 TRANSFER 子节点双重渲染
const flatMap: Record<string, TaskResponse> = {}; const mains: 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[] = [];
for (const t of all) { for (const t of all) {
if (isMain(t)) { if (!t.parent_task_id && isMain(t)) mains.push(t);
if (active(t.status)) am.push(t); else hm.push(t);
srm[t.id] = [];
} }
} mains.sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime());
// 非主线任务:按根主干分组
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());
// 🚀 构建全局 childMap(按 parent_task_id 索引直接子节点,保留真实树结构) // 🚀 构建全局 childMap(按 parent_task_id 索引直接子节点,保留真实树结构)
const childMap: Record<string, TaskResponse[]> = {}; const childMap: Record<string, TaskResponse[]> = {};
for (const t of all) { for (const t of all) {
@ -144,11 +116,9 @@ export const TaskFlowView = memo(function TaskFlowView({ tasks, onAction, curren
childMap[pid].push(t); 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())); 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]); }, [tasks]);
const allMains = [...historicalMain, ...activeMain];
// 🚀 递归渲染分支节点 — 每个节点从自己的 childMap 获取直系子孙,保持树结构不断裂 // 🚀 递归渲染分支节点 — 每个节点从自己的 childMap 获取直系子孙,保持树结构不断裂
const renderBranch = (node: TaskResponse, side: 'left' | 'right', isLegacy: boolean, rootMainId: string): JSX.Element => { const renderBranch = (node: TaskResponse, side: 'left' | 'right', isLegacy: boolean, rootMainId: string): JSX.Element => {
const kids = childMap[node.id] || []; 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 ( return (
<div> <div>
{/* 模式切换 */} {/* 模式切换 */}
@ -182,39 +159,9 @@ export const TaskFlowView = memo(function TaskFlowView({ tasks, onAction, curren
</button> </button>
</div> </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="flex-1 flex flex-col items-end justify-center gap-2 pr-2">
{leftDirect.map(t => renderBranch(t, 'left', isLegacy, 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>
{/* 右翼 — 递归渲染,子子孙孙向外延伸 */}
<div className="flex-1 flex flex-col items-start justify-center gap-2 pl-2">
{rightDirect.map(t => renderBranch(t, 'right', isLegacy, mainTask.id))}
</div>
</div>
);
})}
{activeMain.length === 0 && <p className="text-xs text-gray-400 py-4">当前无活跃主线任务</p>}
</div>
)}
{/* ─── 全景模式 ─── */}
{showFullMap && (
<div className="space-y-6"> <div className="space-y-6">
{allMains.map(mainTask => { {visibleMains.map(mainTask => {
const directChildren = childMap[mainTask.id] || []; const directChildren = childMap[mainTask.id] || [];
const hasLegacyActive = directChildren.some(c => !isMain(c) && !active(mainTask.status)); const hasLegacyActive = directChildren.some(c => !isMain(c) && !active(mainTask.status));
const leftDirect = directChildren.filter((_, i) => i % 2 === 0); const leftDirect = directChildren.filter((_, i) => i % 2 === 0);
@ -243,7 +190,7 @@ export const TaskFlowView = memo(function TaskFlowView({ tasks, onAction, curren
</div> </div>
</div> </div>
{allMains.indexOf(mainTask) < allMains.length - 1 && ( {visibleMains.indexOf(mainTask) < visibleMains.length - 1 && (
<div className="flex justify-center py-2"> <div className="flex justify-center py-2">
<span className="text-[10px] text-gray-300">▼</span> <span className="text-[10px] text-gray-300">▼</span>
</div> </div>
@ -251,9 +198,12 @@ export const TaskFlowView = memo(function TaskFlowView({ tasks, onAction, curren
</div> </div>
); );
})} })}
{allMains.length === 0 && <p className="text-center text-xs text-gray-400 py-8">暂无流转记录</p>} {visibleMains.length === 0 && (
</div> <p className="text-center text-xs text-gray-400 py-8">
{showFullMap ? "暂无流转记录" : "当前无活跃主线任务"}
</p>
)} )}
</div>
{/* 记录弹窗 */} {/* 记录弹窗 */}
{recordsTask && ( {recordsTask && (