From ae520e49d729e516e8f8ecf7a0697de66fb6e70d Mon Sep 17 00:00:00 2001 From: duxingchen Date: Mon, 10 Aug 2026 13:12:23 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20=E5=8F=8C=E6=A8=A1=E5=BC=8F?= =?UTF-8?q?=E6=B5=81=E8=BD=AC=E6=A0=91=E2=80=94=E7=84=A6=E7=82=B9=E6=A8=A1?= =?UTF-8?q?=E5=BC=8F(=E6=B4=BB=E8=B7=83+=E9=81=97=E7=95=99=E5=88=86?= =?UTF-8?q?=E6=94=AF)=20+=20=E5=85=A8=E6=99=AF=E6=A8=A1=E5=BC=8F(=E5=AE=8C?= =?UTF-8?q?=E6=95=B4=E5=8E=86=E5=8F=B2=E6=97=B6=E9=97=B4=E8=BD=B4)=20+=20?= =?UTF-8?q?=E6=9E=81=E7=AE=80=E5=8D=A1=E7=89=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/components/TaskTree/TaskFlowView.tsx | 526 +++++++----------- 1 file changed, 186 insertions(+), 340 deletions(-) diff --git a/frontend/src/components/TaskTree/TaskFlowView.tsx b/frontend/src/components/TaskTree/TaskFlowView.tsx index e05d34d..1ffcb00 100644 --- a/frontend/src/components/TaskTree/TaskFlowView.tsx +++ b/frontend/src/components/TaskTree/TaskFlowView.tsx @@ -1,353 +1,73 @@ /** - * 任务流转树 — 递归树形渲染,与移动端 TaskSwipeCards 核心逻辑对齐 - * - * 渲染规则: - * - 顶层任务 (!parent_task_id) = 主分支(蓝色) - * - TRANSFER/RECOVERY 子任务 = 主干下一环(同缩进,蓝色) - * - SPAWN 子任务 = 分支(缩进 + 紫色 + 左侧连接线) + * 流转树双模式可视化 — 焦点模式 + 全景模式 */ -import { memo, useState } from "react"; -import { - GitBranch, AlertTriangle, Clock, - CheckCircle, Flag, FileText, X, -} from "lucide-react"; +import { memo, useMemo, useState } from "react"; +import { GitBranch, AlertTriangle, Clock, CheckCircle, Flag, FileText, 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 && !!t.parent_task_id); } +function active(s: string) { return s === "WIP" || s === "PENDING"; } +function parseImages(s: string | null | undefined): string[] { if (!s) return []; try { return JSON.parse(s); } catch { return []; } } +function imageUrl(u: string) { if (!u) return ""; return u.startsWith("http") ? u : import.meta.env.VITE_API_BASE_URL + (u.startsWith("/") ? u : "/" + u); } -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())}`; -} - -/** 主线判定:与移动端 branchLabelMap/isMain 完全一致 */ -function isMainTask(t: TaskResponse): boolean { - return !t.parent_task_id - || t.task_type === "TRANSFER" - || t.task_type === "RECOVERY" - || (!t.task_type && !!t.parent_task_id); -} - -function parseImages(s: string | null | undefined): string[] { - if (!s) return []; - try { return JSON.parse(s); } catch { return []; } -} - -function imageUrl(url: string) { - if (!url) return ""; - if (url.startsWith("http")) return url; - return import.meta.env.VITE_API_BASE_URL + (url.startsWith("/") ? url : "/" + url); -} - -/** 排序:主线优先,再按时间 */ -function sortTasks(tasks: TaskResponse[]): TaskResponse[] { - return [...tasks].sort((a, b) => { - const aMain = isMainTask(a) ? -1 : 1; - const bMain = isMainTask(b) ? -1 : 1; - if (aMain !== bMain) return aMain - bMain; - return new Date(a.created_at).getTime() - new Date(b.created_at).getTime(); - }); -} +const ALL_TASKS = new Set(); +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; } // ============================================================ -// 停留耗时 +// 极简卡片 // ============================================================ - -function calcDwell(receivedAt: string | null, completedAt: string | null, status: string): { text: string; highlight: boolean } | null { - if (!receivedAt) return null; - const start = new Date(receivedAt).getTime(); - const end = completedAt ? new Date(completedAt).getTime() : Date.now(); - const diffMs = end - start; - if (diffMs < 0) return null; - const m = Math.floor(diffMs / 60000); - if (m < 1) return { text: "< 1分钟", highlight: status === "WIP" }; - if (m < 60) return { text: `${m}分钟`, highlight: status === "WIP" }; - const h = Math.floor(m / 60); const rm = m % 60; - if (h < 24) return { text: `${h}小时${rm > 0 ? rm + "分钟" : ""}`, highlight: status === "WIP" }; - const d = Math.floor(h / 24); const rh = h % 24; - return { text: `${d}天${rh > 0 ? rh + "小时" : ""}`, highlight: status === "WIP" }; -} - -// ============================================================ -// 单张任务卡片 -// ============================================================ - -const TaskCard = memo(function TaskCard({ - task, isActive, onAction, currentUser, assigneeName, isBranch, - onViewRecords, +const SlimCard = memo(function SlimCard({ + task, assigneeName, active, legacy, onAction, currentUser, onViewRecords, }: { - task: TaskResponse; isActive: boolean; - onAction: (t: ModalTarget) => void; - currentUser?: { username?: string; role?: string } | null; - assigneeName?: string; isBranch?: boolean; + task: TaskResponse; assigneeName?: string; active: boolean; legacy?: boolean; + onAction: (t: ModalTarget) => void; currentUser?: { username?: string; role?: string } | null; onViewRecords?: (t: TaskResponse) => void; }) { const cfg = getStatusConfig(task.status); - const dwell = calcDwell(task.received_at, task.completed_at, task.status); - const isCompleted = task.status?.toUpperCase() === "COMPLETED"; - const isArchived = task.status?.toUpperCase() === "ARCHIVED"; - const isCanceled = task.status?.toUpperCase() === "CANCELED"; - const main = isMainTask(task); - const isOwner = !!(currentUser?.username && task.assignee_id === currentUser.username); const isManager = currentUser?.role === "SUPER_ADMIN" || currentUser?.role === "SUPERVISOR"; + const main = isMain(task); return ( -
- {/* 角标:主分支(蓝) / 分支(紫) */} -
- {main ? "主分支" : "分支"} +
+
{main ? "主线" : "分支"}
+

{task.task_name}

+
+ {cfg.label} + {assigneeName || task.assignee_id || "—"}
- - {/* 返工 / 入库 */} -
- {task.is_rework && 返工} - {task.status === "ARCHIVED" && 📦 入库} -
- - {/* 类型标签 + 状态 */} -
- - {main ? "主分支" : "分支"} - - {task.child_tasks.length > 1 && ( - - 裂变×{task.child_tasks.length} - - )} - {cfg.label} -
- -

{task.task_name}

- - {/* 负责人 */} -
- 👤 负责人 - {assigneeName || task.assignee_id || "未分配"} -
- - {/* 备注 */} - {task.remark && ( -
-

📌 备注

-

{task.remark}

+ {/* 单行时间 */} +

+ ⏰ {fmtTime(task.created_at).split(" ")[0]} + {task.completed_at ? ` → ${fmtTime(task.completed_at).split(" ")[0]}` : " → 至今"} +

+ {legacy &&

源自: {assigneeName || (findParent(task)?.assignee_id) || "历史任务"}

} + {/* 操作按钮 */} + {active && isOwner && ( +
+ {task.status?.toUpperCase() === TASK_STATUS.PENDING && } + {task.status?.toUpperCase() !== TASK_STATUS.PENDING && } +
)} - - {/* 提交记录 */} + {active && !isOwner && isManager && ( +
+ + +
+ )} + {/* 记录 */} {task.records && task.records.length > 0 && ( -
{ e.stopPropagation(); onViewRecords?.(task); }} - className="mt-2 flex cursor-pointer items-center gap-1.5 rounded-lg border border-blue-200 bg-gradient-to-r from-blue-50 to-indigo-50 px-2.5 py-2 transition-colors hover:from-blue-100 hover:to-indigo-100"> - - 共 {task.records.length} 条 - 查看 › -
- )} - - {/* 耗时 */} - {dwell && ( -

- - {dwell.highlight ? ⏳ 停留: {dwell.text} : 耗时: {dwell.text}} -

- )} - - {task.reject_reason &&

驳回原因: {task.reject_reason}

} - - {/* 状态标记 */} -
- {task.received_at && 已接收} - {task.completed_at && 已完工} -
- -
-

创建: {fmtTime(task.created_at)}

- {task.received_at &&

接收: {fmtTime(task.received_at)}

} - {task.completed_at &&

完工: {fmtTime(task.completed_at)}

} -
- - {/* 权限按钮 — 严格互斥 */} - {isActive && isOwner && ( -
- {task.status?.toUpperCase() === TASK_STATUS.PENDING && (<> - - - - )} - {task.status?.toUpperCase() === TASK_STATUS.WIP && (<> - - - )} -
- )} - {isActive && !isOwner && isManager && ( -
-

⚠ 运维干预

-
- - -
-
- )} - {isActive && !isOwner && !isManager && ( -
-

非当前任务责任人

-
- )} -
- ); -}); - -// ============================================================ -// 记录弹窗 -// ============================================================ - -const RecordsModal = memo(function RecordsModal({ - open, task, onClose, -}: { open: boolean; task: TaskResponse | null; onClose: () => void }) { - if (!open || !task) return null; - const records = task.records || []; - return ( -
-
-
-
-

提交记录

{task.task_name}

- -
-
- {records.length === 0 ? ( -
📭

暂无历史记录

- ) : ( -
- {[...records].reverse().map((rec, i) => ( -
-
-
- {i < records.length - 1 &&
} -
-
-

{fmtTime(rec.created_at)}

- {rec.note &&

{rec.note}

} - {rec.remark &&

{rec.remark}

} - {(() => { - const imgs = parseImages((rec as any).images); - if (!imgs.length) return null; - return
{imgs.map((img, j) => window.open(imageUrl(img), "_blank")} />)}
; - })()} -
-
- ))} -
- )} -
-
-
- ); -}); - -// ============================================================ -// 🚀 递归任务节点 — 替代 flattenLevels -// ============================================================ - -interface CommonProps { - onAction: (t: ModalTarget) => void; - currentUser?: { username?: string; role?: string } | null; - assigneeNames?: Record; - onViewRecords: (t: TaskResponse) => void; -} - -const MAX_DEPTH = 10; // 🔧 防爆栈:递归深度上限 - -const TaskNode = memo(function TaskNode({ - task, isBranch, common, depth = 0, -}: { - task: TaskResponse; isBranch: boolean; - common: CommonProps; depth?: number; -}) { - // 🔧 防循环引用/脏数据导致死循环 - if (depth > MAX_DEPTH) { - return ( -
-

⚠ 树深度超限

-

请检查数据完整性

-
- ); - } - - const isActive = task.status?.toUpperCase() === TASK_STATUS.PENDING - || task.status?.toUpperCase() === TASK_STATUS.WIP; - - if (!task.child_tasks || task.child_tasks.length === 0) { - return ( - - ); - } - - // 分子任务:TRANSFER = 主干(同缩进),SPAWN = 分支(缩进) - const sorted = sortTasks(task.child_tasks); - const mainChildren = sorted.filter(c => isMainTask(c)); - const spawnChildren = sorted.filter(c => !isMainTask(c)); - - return ( -
- {/* 主干连接线 */} - {mainChildren.length > 0 && ( -
- )} - - {/* 当前节点卡片 */} - - - {/* TRANSFER 子任务:主干下一环,同缩进,带竖线连接 */} - {mainChildren.length > 0 && ( -
- {/* 竖线 */} -
-
- {mainChildren.map((child) => ( -
- {/* 横线连接 */} -
-
- -
-
- ))} -
-
- )} - - {/* SPAWN 子任务:分支,缩进 + 紫色左边线 */} - {spawnChildren.length > 0 && ( -
- {spawnChildren.map((child, idx) => ( -
0 ? "mt-2" : ""}> - -
- ))} +
{ e.stopPropagation(); onViewRecords?.(task); }} className="mt-1 cursor-pointer rounded bg-blue-50 px-1.5 py-0.5 text-[8px] text-blue-600 hover:bg-blue-100"> + {task.records.length}条
)}
@@ -357,32 +77,158 @@ const TaskNode = memo(function TaskNode({ // ============================================================ // 主视图 // ============================================================ - interface TaskFlowViewProps { - tasks: TaskResponse[]; - onAction: (target: ModalTarget) => void; + tasks: TaskResponse[]; onAction: (t: ModalTarget) => void; currentUser?: { username?: string; role?: string } | null; assigneeNames?: Record; } export const TaskFlowView = memo(function TaskFlowView({ tasks, onAction, currentUser, assigneeNames }: TaskFlowViewProps) { + const [showFullMap, setShowFullMap] = useState(false); const [recordsTask, setRecordsTask] = useState(null); - if (!tasks || tasks.length === 0) return null; - const common: CommonProps = { onAction, currentUser, assigneeNames, onViewRecords: setRecordsTask }; + // 数据分类 + const { activeMain, historicalMain, activeSubs, legacySubs } = 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[] = []; + 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); + } + } + } + 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 }; + }, [tasks]); + + const allMains = [...historicalMain, ...activeMain]; return ( -
-
- 流转树 -
+
+ {/* 模式切换 */} +
+
-
- {sortTasks(tasks).map((task) => ( - - ))} -
- setRecordsTask(null)} /> + + {/* ─── 焦点模式 ─── */} + {!showFullMap && ( +
+ {/* 顶部行:遗留分支标记 */} + {legacySubs.length > 0 && ( +
+

⚠ 以下 {legacySubs.length} 个分支属于已完工的历史主线,仍在进行中

+
+ )} + + {/* 中央:activeMain 纵向排列 */} +
+ {activeMain.map(t => ( + + ))} + {activeMain.length === 0 &&

当前无活跃主线任务

} +
+ + {/* 两侧:activeSubs + legacySubs 与中央同行 */} +
+ {/* activeSubs(实线) */} + {activeSubs.map(t => ( +
+
+
+ +
+ ))} + {/* legacySubs(虚线+来源标注) */} + {legacySubs.map(t => ( +
+
+
+ +
+ ))} +
+
+ )} + + {/* ─── 全景模式 ─── */} + {showFullMap && ( +
+ {allMains.map(mainTask => { + const children = Array.from(ALL_TASKS).filter(t => t.parent_task_id === mainTask.id); + const activeChildren = children.filter(c => active(c.status)); + const hasLegacyActive = activeChildren.some(c => !isMain(c) && !active(mainTask.status)); + + return ( +
+ {/* 垂直轴线 */} +
+ + {/* 主干卡片居中 */} +
+
+ + {active(mainTask.status) && ( +
+ )} +
+
+ + {/* 分支水平排列 */} + {children.length > 0 && ( +
+ {children.map(c => ( +
+
+ +
+ ))} +
+ )} + + {/* 区间连接线 */} + {allMains.indexOf(mainTask) < allMains.length - 1 && ( +
+ +
+ )} +
+ ); + })} + {allMains.length === 0 &&

暂无流转记录

} +
+ )} + + {/* 记录弹窗 */} + {recordsTask && ( +
+
setRecordsTask(null)} /> +
+

提交记录 — {recordsTask.task_name}

+ {(recordsTask.records || []).length === 0 ?

暂无记录

: +
{[...recordsTask.records!].reverse().map((r, i) => ( +
+
+

{fmtTime(r.created_at)}

+ {(r.note || r.remark) &&

{r.note || r.remark}

} + {(() => { const imgs = parseImages((r as any).images); if (!imgs.length) return null; return
{imgs.map((img, j) => window.open(imageUrl(img))} />)}
; })()} +
+
+ ))}
} +
+
+ )}
); });