diff --git a/frontend/src/components/TaskTree/TaskFlowView.tsx b/frontend/src/components/TaskTree/TaskFlowView.tsx index b255a67..a6062ca 100644 --- a/frontend/src/components/TaskTree/TaskFlowView.tsx +++ b/frontend/src/components/TaskTree/TaskFlowView.tsx @@ -1,12 +1,17 @@ /** - * 任务流转卡片堆叠视图 — 与移动端 TaskSwipeCards.vue 核心逻辑完全对齐 + * 任务流转树 — 递归树形渲染,与移动端 TaskSwipeCards 核心逻辑对齐 + * + * 渲染规则: + * - 顶层任务 (!parent_task_id) = 主分支(蓝色) + * - TRANSFER/RECOVERY 子任务 = 主干下一环(同缩进,蓝色) + * - SPAWN 子任务 = 分支(缩进 + 紫色 + 左侧连接线) */ -import { memo, useMemo, useState } from "react"; +import { memo, useState } from "react"; import { - GitBranch, ArrowDown, AlertTriangle, Clock, - CheckCircle, Flag, FileText, X, Image, + GitBranch, AlertTriangle, Clock, + CheckCircle, Flag, FileText, X, } from "lucide-react"; -import type { TaskResponse, TaskRecordResponse } from "../../types/api"; +import type { TaskResponse } from "../../types/api"; import { TASK_STATUS } from "../../types/api"; import { getStatusConfig } from "../../constants/task"; import type { ModalTarget } from "./TaskTreeViewer"; @@ -22,18 +27,17 @@ function fmtTime(d: string | null) { 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" + return !t.parent_task_id + || t.task_type === "TRANSFER" + || t.task_type === "RECOVERY" || (!t.task_type && !!t.parent_task_id); } -// ============================================================ -// 记录图片渲染 -// ============================================================ - -function parseImages(imagesStr: string | null | undefined): string[] { - if (!imagesStr) return []; - try { return JSON.parse(imagesStr); } catch { return []; } +function parseImages(s: string | null | undefined): string[] { + if (!s) return []; + try { return JSON.parse(s); } catch { return []; } } function imageUrl(url: string) { @@ -42,59 +46,53 @@ function imageUrl(url: string) { return import.meta.env.VITE_API_BASE_URL + (url.startsWith("/") ? url : "/" + url); } -// ============================================================ -// 类型 -// ============================================================ - -interface LevelGroup { depth: number; tasks: TaskResponse[]; } - -// ============================================================ -// 任务树解析 -// ============================================================ - -function flattenLevels(tasks: TaskResponse[], depth: number = 0): LevelGroup[] { - const result: LevelGroup[] = []; - if (!tasks || tasks.length === 0) return result; - result.push({ depth, tasks }); - for (const t of tasks) { - if (t.child_tasks && t.child_tasks.length > 0) { - result.push(...flattenLevels(t.child_tasks, depth + 1)); - } - } - return result; +/** 排序:主线优先,再按时间 */ +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(); + }); } -function groupByDepth(levels: LevelGroup[]): Map { - const map = new Map(); - for (const lvl of levels) { - if (!map.has(lvl.depth)) map.set(lvl.depth, []); - const existing = map.get(lvl.depth)!; - for (const t of lvl.tasks) { - if (!existing.find((e) => e.id === t.id)) existing.push(t); - } - } - return map; +// ============================================================ +// 停留耗时 +// ============================================================ + +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 FlowCard = memo(function FlowCard({ - task, isActive, onAction, currentUser, assigneeName, +const TaskCard = memo(function TaskCard({ + task, isActive, onAction, currentUser, assigneeName, isBranch, onViewRecords, }: { - task: TaskResponse; - isActive: boolean; - onAction: (target: ModalTarget) => void; + task: TaskResponse; isActive: boolean; + onAction: (t: ModalTarget) => void; currentUser?: { username?: string; role?: string } | null; - assigneeName?: string; - onViewRecords?: (task: TaskResponse) => void; + assigneeName?: string; isBranch?: boolean; + 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() === TASK_STATUS.COMPLETED; - const isArchived = task.status?.toUpperCase() === TASK_STATUS.ARCHIVED; + const isCompleted = task.status?.toUpperCase() === "COMPLETED"; + const isArchived = task.status?.toUpperCase() === "ARCHIVED"; const isCanceled = task.status?.toUpperCase() === "CANCELED"; const main = isMainTask(task); @@ -103,35 +101,37 @@ const FlowCard = memo(function FlowCard({ return (
- {/* 主线/分支 角标 */} -
+ {/* 角标:主分支(蓝) / 分支(紫) */} +
{main ? "主分支" : "分支"}
- {/* 返工/入库 */} + {/* 返工 / 入库 */}
{task.is_rework && 返工} {task.status === "ARCHIVED" && 📦 入库}
- {/* 头部 */} + {/* 类型标签 + 状态 */}
-
- {main ? "主分支" : "分支"} - {task.child_tasks.length > 1 && 裂变×{task.child_tasks.length}} -
+ + {main ? "主分支" : "分支"} + + {task.child_tasks.length > 1 && ( + + 裂变×{task.child_tasks.length} + + )} {cfg.label}
- {/* 任务名 */}

{task.task_name}

{/* 负责人 */} @@ -148,14 +148,12 @@ const FlowCard = memo(function FlowCard({
)} - {/* 提交记录 — 可点击 */} + {/* 提交记录 */} {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" - > +
{ 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} 条提交记录 + 共 {task.records.length} 条 查看 ›
)} @@ -168,7 +166,6 @@ const FlowCard = memo(function FlowCard({

)} - {/* 驳回原因 */} {task.reject_reason &&

驳回原因: {task.reject_reason}

} {/* 状态标记 */} @@ -177,48 +174,38 @@ const FlowCard = memo(function FlowCard({ {task.completed_at && 已完工}
- {/* 时间 */}

创建: {fmtTime(task.created_at)}

{task.received_at &&

接收: {fmtTime(task.received_at)}

} {task.completed_at &&

完工: {fmtTime(task.completed_at)}

}
- {/* 🔧 BugFix #1: 权限严格互斥 */} - {/* 本人:正常操作按钮 */} + {/* 权限按钮 — 严格互斥 */} {isActive && isOwner && (
- {task.status?.toUpperCase() === TASK_STATUS.PENDING && ( - <> - - - - - )} - {task.status?.toUpperCase() === TASK_STATUS.WIP && ( - <> - - - - )} + {task.status?.toUpperCase() === TASK_STATUS.PENDING && (<> + + + + )} + {task.status?.toUpperCase() === TASK_STATUS.WIP && (<> + + + )}
)} - - {/* 管理员看别人:仅干预按钮 */} {isActive && !isOwner && isManager && (
-

⚠ 运维干预模式

+

⚠ 运维干预

)} - - {/* 普通人看别人:什么按钮都没有 */} {isActive && !isOwner && !isManager && (
-

非当前任务责任人,无法操作

+

非当前任务责任人

)}
@@ -226,61 +213,41 @@ const FlowCard = memo(function FlowCard({ }); // ============================================================ -// 记录查看弹窗 — 与移动端 records 页面样式对齐 +// 记录弹窗 // ============================================================ -export const RecordsModal = memo(function RecordsModal({ +const RecordsModal = memo(function RecordsModal({ open, task, onClose, -}: { - open: boolean; task: TaskResponse | null; onClose: () => void; -}) { +}: { open: boolean; task: TaskResponse | null; onClose: () => void }) { if (!open || !task) return null; const records = task.records || []; - return (
-
-

提交记录

-

{task.task_name}

-
+

提交记录

{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")} /> - ))} -
- ); + return
{imgs.map((img, j) => window.open(imageUrl(img), "_blank")} />)}
; })()}
@@ -294,26 +261,87 @@ export const RecordsModal = memo(function RecordsModal({ }); // ============================================================ -// 停留耗时 +// 🚀 递归任务节点 — 替代 flattenLevels // ============================================================ -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 totalMin = Math.floor(diffMs / 60000); - if (totalMin < 1) return { text: "< 1分钟", highlight: status === "WIP" }; - if (totalMin < 60) return { text: `${totalMin}分钟`, highlight: status === "WIP" }; - const hours = Math.floor(totalMin / 60); - const remainMin = totalMin % 60; - if (hours < 24) return { text: `${hours}小时${remainMin > 0 ? remainMin + "分钟" : ""}`, highlight: status === "WIP" }; - const days = Math.floor(hours / 24); - const remainHr = hours % 24; - return { text: `${days}天${remainHr > 0 ? remainHr + "小时" : ""}`, highlight: status === "WIP" }; +interface CommonProps { + onAction: (t: ModalTarget) => void; + currentUser?: { username?: string; role?: string } | null; + assigneeNames?: Record; + onViewRecords: (t: TaskResponse) => void; } +const TaskNode = memo(function TaskNode({ + task, isBranch, common, +}: { + task: TaskResponse; isBranch: boolean; + common: CommonProps; +}) { + 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" : ""}> + +
+ ))} +
+ )} +
+ ); +}); + // ============================================================ // 主视图 // ============================================================ @@ -327,48 +355,23 @@ interface TaskFlowViewProps { export const TaskFlowView = memo(function TaskFlowView({ tasks, onAction, currentUser, assigneeNames }: TaskFlowViewProps) { const [recordsTask, setRecordsTask] = useState(null); + if (!tasks || tasks.length === 0) return null; - const depthMap = useMemo(() => { - if (!tasks || tasks.length === 0) return new Map(); - return groupByDepth(flattenLevels(tasks)); - }, [tasks]); - - const depths = Array.from(depthMap.keys()).sort((a, b) => a - b); - if (depths.length === 0) return null; + const common: CommonProps = { onAction, currentUser, assigneeNames, onViewRecords: setRecordsTask }; return ( - <> -
- {depths.map((depth) => { - const levelTasks = depthMap.get(depth) || []; - return ( -
-
- {depth === 0 ? "顶层任务" : `第 ${depth} 层子任务`} -
-
-
- {levelTasks.map((task) => { - const isActive = task.status?.toUpperCase() === TASK_STATUS.PENDING || task.status?.toUpperCase() === TASK_STATUS.WIP; - return ( -
- -
- ); - })} -
- {depth < depths.length - 1 && ( -
- )} -
- ); - })} +
+
+ 流转树 +
+
+
+ {sortTasks(tasks).map((task) => ( + + ))}
setRecordsTask(null)} /> - +
); });