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:
@ -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,78 +159,51 @@ export const TaskFlowView = memo(function TaskFlowView({ tasks, onAction, curren
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ─── 焦点模式 ─── */}
|
{/* ─── 统一垂直时间线布局(焦点/全景共用) ─── */}
|
||||||
{!showFullMap && (
|
<div className="space-y-6">
|
||||||
<div className="flex flex-col items-center gap-4">
|
{visibleMains.map(mainTask => {
|
||||||
{activeMain.map(mainTask => {
|
const directChildren = childMap[mainTask.id] || [];
|
||||||
const directChildren = childMap[mainTask.id] || [];
|
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);
|
||||||
const rightDirect = directChildren.filter((_, i) => i % 2 === 1);
|
const rightDirect = directChildren.filter((_, i) => i % 2 === 1);
|
||||||
const isLegacy = !active(mainTask.status);
|
const lineStyle = hasLegacyActive && !active(mainTask.status);
|
||||||
return (
|
|
||||||
<div key={mainTask.id} className="flex flex-row items-start w-full">
|
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">
|
<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>
|
||||||
{/* 中央主干 */}
|
{/* 中央 */}
|
||||||
<div className="shrink-0 z-10">
|
<div className="shrink-0 z-10 relative">
|
||||||
<SlimCard task={mainTask} active assigneeName={assigneeNames?.[mainTask.assignee_id || ""]} onAction={onAction} currentUser={currentUser} onViewRecords={setRecordsTask} />
|
<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>
|
||||||
{/* 右翼 — 递归渲染,子子孙孙向外延伸 */}
|
{/* 右翼 — 递归渲染,子子孙孙向外延伸 */}
|
||||||
<div className="flex-1 flex flex-col items-start justify-center gap-2 pl-2">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
);
|
|
||||||
})}
|
|
||||||
{activeMain.length === 0 && <p className="text-xs text-gray-400 py-4">当前无活跃主线任务</p>}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* ─── 全景模式 ─── */}
|
{visibleMains.indexOf(mainTask) < visibleMains.length - 1 && (
|
||||||
{showFullMap && (
|
<div className="flex justify-center py-2">
|
||||||
<div className="space-y-6">
|
<span className="text-[10px] text-gray-300">▼</span>
|
||||||
{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>
|
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
{allMains.indexOf(mainTask) < allMains.length - 1 && (
|
</div>
|
||||||
<div className="flex justify-center py-2">
|
);
|
||||||
<span className="text-[10px] text-gray-300">▼</span>
|
})}
|
||||||
</div>
|
{visibleMains.length === 0 && (
|
||||||
)}
|
<p className="text-center text-xs text-gray-400 py-8">
|
||||||
</div>
|
{showFullMap ? "暂无流转记录" : "当前无活跃主线任务"}
|
||||||
);
|
</p>
|
||||||
})}
|
)}
|
||||||
{allMains.length === 0 && <p className="text-center text-xs text-gray-400 py-8">暂无流转记录</p>}
|
</div>
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 记录弹窗 */}
|
{/* 记录弹窗 */}
|
||||||
{recordsTask && (
|
{recordsTask && (
|
||||||
|
|||||||
Reference in New Issue
Block a user