refactor: Web 端十字星流转树 — 3层嵌套协助降维渲染
- isMain() 移除危险的 parent.status 兜底判定,仅凭基因字段
- 新增 getRootMainId() 寻根算法,向上攀爬找视觉主干
- subsByRootMain 虚拟扁平化:孙子协助挂在顶级主干下
- 层级排序:直接子节点 → 孙子按父链收集
- SlimCard 新增嵌套协助标识:协助: {真实parent.assignee_id}
This commit is contained in:
@ -12,7 +12,17 @@ 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 && !!t.parent_task_id); }
|
||||
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 }) {
|
||||
@ -33,16 +43,18 @@ function findParent(child: TaskResponse): TaskResponse | undefined { for (const
|
||||
// 极简卡片
|
||||
// ============================================================
|
||||
const SlimCard = memo(function SlimCard({
|
||||
task, assigneeName, active, legacy, onAction, currentUser, onViewRecords,
|
||||
task, assigneeName, active, legacy, onAction, currentUser, onViewRecords, rootMainId,
|
||||
}: {
|
||||
task: TaskResponse; assigneeName?: string; active: boolean; legacy?: boolean;
|
||||
onAction: (t: ModalTarget) => void; currentUser?: { username?: string; role?: string } | null;
|
||||
onViewRecords?: (t: TaskResponse) => void;
|
||||
rootMainId?: string;
|
||||
}) {
|
||||
const cfg = getStatusConfig(task.status);
|
||||
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;
|
||||
|
||||
return (
|
||||
<div className={`relative w-44 shrink-0 rounded-lg border bg-white p-2.5 shadow-sm ${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" : ""}`}>
|
||||
@ -58,6 +70,7 @@ const SlimCard = memo(function SlimCard({
|
||||
{task.completed_at ? ` → ${fmtTime(task.completed_at).split(" ")[0]}` : " → 至今"}
|
||||
</p>
|
||||
{legacy && <p className="mt-1 text-[8px] text-orange-500">源自: {assigneeName || (findParent(task)?.assignee_id) || "历史任务"}</p>}
|
||||
{isNestedSpawn && <p className="mt-1 text-[8px] text-purple-500">协助: {findParent(task)?.assignee_id || "—"}</p>}
|
||||
{/* 操作按钮 */}
|
||||
{active && isOwner && (
|
||||
<div className="mt-1.5 flex gap-1 border-t border-gray-100 pt-1.5">
|
||||
@ -95,24 +108,58 @@ export const TaskFlowView = memo(function TaskFlowView({ tasks, onAction, curren
|
||||
const [showFullMap, setShowFullMap] = useState(false);
|
||||
const [recordsTask, setRecordsTask] = useState<TaskResponse | null>(null);
|
||||
|
||||
// 数据分类
|
||||
const { activeMain, historicalMain, activeSubs, legacySubs } = useMemo(() => {
|
||||
// 数据分类 — 🚀 虚拟扁平化:三层+嵌套协助统一挂在视觉主干下
|
||||
const { activeMain, historicalMain, subsByRootMain, rootMainMap } = useMemo(() => {
|
||||
ALL_TASKS.clear(); if (tasks.length) collectAll(tasks);
|
||||
const all = Array.from(ALL_TASKS);
|
||||
const am: TaskResponse[] = []; const hm: TaskResponse[] = []; const as: TaskResponse[] = []; const ls: TaskResponse[] = [];
|
||||
// 拍平映射
|
||||
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[] = [];
|
||||
for (const t of all) {
|
||||
if (isMain(t)) {
|
||||
if (active(t.status)) am.push(t); else hm.push(t);
|
||||
} else {
|
||||
if (active(t.status)) {
|
||||
const p = findParent(t);
|
||||
if (p && active(p.status)) as.push(t); else ls.push(t);
|
||||
}
|
||||
srm[t.id] = [];
|
||||
}
|
||||
}
|
||||
// 非主线任务:按根主干分组
|
||||
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());
|
||||
return { activeMain: am, historicalMain: hm, activeSubs: as, legacySubs: ls };
|
||||
// 🚀 层级排序:直接子节点优先,嵌套子节点跟在父节点之后
|
||||
for (const key of Object.keys(srm)) {
|
||||
const subs = srm[key];
|
||||
// 构建父子索引
|
||||
const childMap: Record<string, TaskResponse[]> = {};
|
||||
for (const s of subs) {
|
||||
const pid = s.parent_task_id || '';
|
||||
if (!childMap[pid]) childMap[pid] = [];
|
||||
childMap[pid].push(s);
|
||||
}
|
||||
Object.values(childMap).forEach(arr => arr.sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime()));
|
||||
// 递归收集:父 → 子 → 孙
|
||||
const ordered: TaskResponse[] = [];
|
||||
const collect = (parentId: string) => {
|
||||
const kids = childMap[parentId] || [];
|
||||
for (const k of kids) {
|
||||
ordered.push(k);
|
||||
collect(k.id);
|
||||
}
|
||||
};
|
||||
collect(key); // 从根主干开始收集
|
||||
srm[key] = ordered;
|
||||
}
|
||||
return { activeMain: am, historicalMain: hm, subsByRootMain: srm, rootMainMap: rootMap };
|
||||
}, [tasks]);
|
||||
|
||||
const allMains = [...historicalMain, ...activeMain];
|
||||
@ -130,25 +177,19 @@ export const TaskFlowView = memo(function TaskFlowView({ tasks, onAction, curren
|
||||
{/* ─── 焦点模式 ─── */}
|
||||
{!showFullMap && (
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
{legacySubs.length > 0 && (
|
||||
<div className="rounded-lg border border-dashed border-orange-200 bg-orange-50 px-3 py-1.5 text-center">
|
||||
<p className="text-[10px] font-medium text-orange-600">⚠ {legacySubs.length} 个遗留分支仍在进行中</p>
|
||||
</div>
|
||||
)}
|
||||
{activeMain.map(mainTask => {
|
||||
const allSubs = [...activeSubs, ...legacySubs];
|
||||
const leftSubs = allSubs.filter((_, i) => i % 2 === 0);
|
||||
const rightSubs = allSubs.filter((_, i) => i % 2 === 1);
|
||||
const children = subsByRootMain[mainTask.id] || [];
|
||||
const leftSubs = children.filter((_, i) => i % 2 === 0);
|
||||
const rightSubs = children.filter((_, i) => i % 2 === 1);
|
||||
return (
|
||||
<div key={mainTask.id} className="flex flex-row items-start w-full">
|
||||
{/* 左翼:flex-col 纵向堆叠,右对齐 */}
|
||||
{/* 左翼 */}
|
||||
<div className="flex-1 flex flex-col items-end justify-center gap-2 pr-2">
|
||||
{leftSubs.map(t => {
|
||||
const isLegacy = legacySubs.includes(t);
|
||||
const isLegacy = !active(mainTask.status);
|
||||
return (
|
||||
<div key={t.id} className="flex items-center">
|
||||
<SlimCard task={t} active legacy={isLegacy} assigneeName={assigneeNames?.[t.assignee_id || ""]} onAction={onAction} currentUser={currentUser} onViewRecords={setRecordsTask} />
|
||||
{/* 左翼:箭头 ◀ 指向分支卡片(背离中央主干) */}
|
||||
<SlimCard task={t} active legacy={isLegacy} assigneeName={assigneeNames?.[t.assignee_id || ""]} onAction={onAction} currentUser={currentUser} onViewRecords={setRecordsTask} rootMainId={mainTask.id} />
|
||||
<div className="flex items-center">
|
||||
<ArrowLeft color={isLegacy ? "#fdba74" : "#9ca3af"} />
|
||||
<div className={`w-5 border-t-2 ${isLegacy ? "border-dashed border-orange-300" : "border-solid border-gray-400"}`} />
|
||||
@ -161,18 +202,17 @@ export const TaskFlowView = memo(function TaskFlowView({ tasks, onAction, curren
|
||||
<div className="shrink-0 z-10">
|
||||
<SlimCard task={mainTask} active assigneeName={assigneeNames?.[mainTask.assignee_id || ""]} onAction={onAction} currentUser={currentUser} onViewRecords={setRecordsTask} />
|
||||
</div>
|
||||
{/* 右翼:flex-col 纵向堆叠,左对齐 */}
|
||||
{/* 右翼 */}
|
||||
<div className="flex-1 flex flex-col items-start justify-center gap-2 pl-2">
|
||||
{rightSubs.map(t => {
|
||||
const isLegacy = legacySubs.includes(t);
|
||||
const isLegacy = !active(mainTask.status);
|
||||
return (
|
||||
<div key={t.id} className="flex items-center">
|
||||
{/* 右翼:箭头 ▶ 指向分支卡片(背离中央主干) */}
|
||||
<div className="flex items-center">
|
||||
<div className={`w-5 border-t-2 ${isLegacy ? "border-dashed border-orange-300" : "border-solid border-gray-400"}`} />
|
||||
<ArrowRight color={isLegacy ? "#fdba74" : "#9ca3af"} />
|
||||
</div>
|
||||
<SlimCard task={t} active legacy={isLegacy} assigneeName={assigneeNames?.[t.assignee_id || ""]} onAction={onAction} currentUser={currentUser} onViewRecords={setRecordsTask} />
|
||||
<SlimCard task={t} active legacy={isLegacy} assigneeName={assigneeNames?.[t.assignee_id || ""]} onAction={onAction} currentUser={currentUser} onViewRecords={setRecordsTask} rootMainId={mainTask.id} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@ -188,7 +228,7 @@ export const TaskFlowView = memo(function TaskFlowView({ tasks, onAction, curren
|
||||
{showFullMap && (
|
||||
<div className="space-y-6">
|
||||
{allMains.map(mainTask => {
|
||||
const children = Array.from(ALL_TASKS).filter(t => t.parent_task_id === mainTask.id);
|
||||
const children = subsByRootMain[mainTask.id] || [];
|
||||
const hasLegacyActive = children.some(c => !isMain(c) && !active(mainTask.status));
|
||||
const leftChildren = children.filter((_, i) => i % 2 === 0);
|
||||
const rightChildren = children.filter((_, i) => i % 2 === 1);
|
||||
@ -196,16 +236,14 @@ export const TaskFlowView = memo(function TaskFlowView({ tasks, onAction, curren
|
||||
|
||||
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">
|
||||
{/* 左翼:flex-col */}
|
||||
{/* 左翼 */}
|
||||
<div className="flex-1 flex flex-col items-end justify-center gap-2 pr-2">
|
||||
{leftChildren.map(c => (
|
||||
<div key={c.id} className="flex items-center">
|
||||
<SlimCard task={c} active={active(c.status)} legacy={lineStyle}
|
||||
assigneeName={assigneeNames?.[c.assignee_id || ""]} onAction={onAction} currentUser={currentUser} onViewRecords={setRecordsTask} />
|
||||
assigneeName={assigneeNames?.[c.assignee_id || ""]} onAction={onAction} currentUser={currentUser} onViewRecords={setRecordsTask} rootMainId={mainTask.id} />
|
||||
<div className="flex items-center">
|
||||
<ArrowLeft color={lineStyle ? "#fdba74" : "#9ca3af"} />
|
||||
<div className={`w-5 border-t-2 ${lineStyle ? "border-dashed border-orange-300" : "border-solid border-gray-400"}`} />
|
||||
@ -221,7 +259,7 @@ export const TaskFlowView = memo(function TaskFlowView({ tasks, onAction, curren
|
||||
<div className="absolute -top-1 -left-1 h-3 w-3 rounded-full bg-green-400 border-2 border-white" />
|
||||
)}
|
||||
</div>
|
||||
{/* 右翼:flex-col */}
|
||||
{/* 右翼 */}
|
||||
<div className="flex-1 flex flex-col items-start justify-center gap-2 pl-2">
|
||||
{rightChildren.map(c => (
|
||||
<div key={c.id} className="flex items-center">
|
||||
@ -230,7 +268,7 @@ export const TaskFlowView = memo(function TaskFlowView({ tasks, onAction, curren
|
||||
<ArrowRight color={lineStyle ? "#fdba74" : "#9ca3af"} />
|
||||
</div>
|
||||
<SlimCard task={c} active={active(c.status)} legacy={lineStyle}
|
||||
assigneeName={assigneeNames?.[c.assignee_id || ""]} onAction={onAction} currentUser={currentUser} onViewRecords={setRecordsTask} />
|
||||
assigneeName={assigneeNames?.[c.assignee_id || ""]} onAction={onAction} currentUser={currentUser} onViewRecords={setRecordsTask} rootMainId={mainTask.id} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user