diff --git a/frontend/src/components/TaskTree/TaskFlowView.tsx b/frontend/src/components/TaskTree/TaskFlowView.tsx index b01473b..f39951e 100644 --- a/frontend/src/components/TaskTree/TaskFlowView.tsx +++ b/frontend/src/components/TaskTree/TaskFlowView.tsx @@ -1,8 +1,10 @@ /** * 任务流转卡片堆叠视图 — 水平分支 + 卡片层叠布局 * - * 将递归任务树按层级拆分为水平分支,每层卡片横向排列, - * 当前激活(WIP/PENDING)卡片高亮居中,操作按钮集成在卡片底部。 + * 与移动端 TaskSwipeCards.vue 核心逻辑完全对齐: + * - 任务树按 TRANSFER/SPAWN 分类为主线/分支 + * - 状态标签、时间格式、卡片元素一致 + * - 三种权限模式(本人/管理员/只读) */ import { memo, useMemo } from "react"; import { @@ -10,83 +12,72 @@ import { ArrowDown, AlertTriangle, Clock, + CheckCircle, + Flag, + FileText, } 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"; -// ---- 耗时计算(复用) ---- +// ============================================================ +// 工具函数 — 与移动端 fmtTime/statusLabel 完全一致 +// ============================================================ -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" }; +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())}`; } -// ---- 类型 ---- +/** 判断任务是否为主线(与移动端 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); +} + +// ============================================================ +// 类型 +// ============================================================ interface LevelGroup { depth: number; tasks: TaskResponse[]; } -// ---- 将递归树拍平为层级 ---- +// ============================================================ +// 任务树解析 — 按 depth 拍平(与移动端 lanes 逻辑结构等价) +// ============================================================ 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) { - const childLevels = flattenLevels(t.child_tasks, depth + 1); - for (const cl of childLevels) { - // 合并同深度的层级 - const existing = result.find((r) => r.depth === cl.depth && r !== result[result.indexOf({ depth, tasks })] ); - // 简化:直接 push,在渲染时按 depth 分组 - } - result.push(...childLevels); + result.push(...flattenLevels(t.child_tasks, depth + 1)); } } - return result; } -/** 按 depth 聚合所有层级 */ function groupByDepth(levels: LevelGroup[]): Map { const map = new Map(); for (const lvl of levels) { if (!map.has(lvl.depth)) map.set(lvl.depth, []); - map.get(lvl.depth)!.push(...lvl.tasks); + 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; } -// ---- 单张任务卡片 ---- +// ============================================================ +// 单张任务卡片 — 与移动端 ss-card 完全对齐 +// ============================================================ const FlowCard = memo(function FlowCard({ task, @@ -105,70 +96,129 @@ const FlowCard = memo(function FlowCard({ 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 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 canOperate = isOwner || isManager; return (
- {/* 返工标记 */} - {task.is_rework && ( -
- 返工 -
- )} + {/* 主线/分支 角标 — 与移动端 tc-ribbon 一致 */} +
+ {main ? "主分支" : "分支"} +
- {/* 裂变标记 */} - {task.child_tasks.length > 1 && ( -
- - 裂变×{task.child_tasks.length} -
- )} - - {/* 任务名 */} -

{task.task_name}

- - {/* 状态 Badge */} -
- - {cfg.label} - - {task.assignee_id && ( - - {assigneeName || task.assignee_id} + {/* 返工/入库标记 — 与移动端 tag-rework-sm 一致 */} +
+ {task.is_rework && ( + + 返工 + + )} + {task.status === "ARCHIVED" && ( + + 📦 入库 )}
- {/* 停留耗时 */} + {/* 头部:类型标签 + 状态 — 与移动端 tc-head 一致 */} +
+
+ + {main ? "主分支" : "分支"} + + {task.child_tasks.length > 1 && ( + + + 裂变×{task.child_tasks.length} + + )} +
+ + {cfg.label} + +
+ + {/* 任务名 — 与移动端 tc-name 一致 */} +

{task.task_name}

+ + {/* 负责人 — 与移动端 tc-meta 一致 */} +
+ 👤 负责人 + {assigneeName || task.assignee_id || "未分配"} +
+ + {/* 备注 — 与移动端 tc-remark-box 一致 */} + {task.remark && ( +
+

📌 备注

+

{task.remark}

+
+ )} + + {/* 提交记录 — 与移动端 tc-records-link 一致 */} + {task.records && task.records.length > 0 && ( +
+ + + 共 {task.records.length} 条提交记录 + +
+ )} + + {/* 停留耗时 — 与移动端逻辑一致 */} {dwell && ( -

+

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

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

{task.reject_reason}

+

驳回原因: {task.reject_reason}

)} - {/* 日期 */} -

- {task.received_at && `接收: ${new Date(task.received_at).toLocaleDateString("zh-CN")}`} -

+ {/* 状态标记 — 与移动端 tc-stats 一致 */} +
+ {task.received_at && ( + + 已接收 + + )} + {task.completed_at && ( + + 已完工 + + )} +
- {/* 操作按钮 — 仅激活态 + 有权限时显示 */} + {/* 时间 — 与移动端 fmtTime 格式一致 */} +
+

创建: {fmtTime(task.created_at)}

+ {task.received_at &&

接收: {fmtTime(task.received_at)}

} + {task.completed_at &&

完工: {fmtTime(task.completed_at)}

} +
+ + {/* 操作按钮 — 三种权限模式 */} {isActive && canOperate && (
{task.status?.toUpperCase() === TASK_STATUS.PENDING && ( @@ -201,47 +251,58 @@ const FlowCard = memo(function FlowCard({ )}
)} - {/* 非本人但管理员可见:干预提示 */} {isActive && !isOwner && isManager && (
-

⚠ 运维干预模式

- {task.status?.toUpperCase() === TASK_STATUS.PENDING && ( -
- - -
- )} - {task.status?.toUpperCase() === TASK_STATUS.WIP && ( -
- - -
- )} +

⚠ 运维干预模式

+
+ + +
)} - {/* 非本人非管理员:只读提示 */} {isActive && !canOperate && (
-

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

+

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

)}
); }); -// ---- 主视图 ---- +// ============================================================ +// 停留耗时 — 与移动端逻辑一致 +// ============================================================ + +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 TaskFlowViewProps { tasks: TaskResponse[]; @@ -251,28 +312,12 @@ interface TaskFlowViewProps { } export const TaskFlowView = memo(function TaskFlowView({ tasks, onAction, currentUser, assigneeNames }: TaskFlowViewProps) { - // 按 depth 分组的层级数据 const depthMap = useMemo(() => { if (!tasks || tasks.length === 0) return new Map(); - const flat = flattenLevels(tasks); - // 按 depth 聚合去重 - const merged = new Map>(); - for (const lvl of flat) { - if (!merged.has(lvl.depth)) merged.set(lvl.depth, new Map()); - const inner = merged.get(lvl.depth)!; - for (const t of lvl.tasks) { - if (!inner.has(t.id)) inner.set(t.id, t); - } - } - const result = new Map(); - for (const [depth, idMap] of merged) { - result.set(depth, Array.from(idMap.values())); - } - return result; + return groupByDepth(flattenLevels(tasks)); }, [tasks]); const depths = Array.from(depthMap.keys()).sort((a, b) => a - b); - if (depths.length === 0) return null; return ( @@ -281,17 +326,13 @@ export const TaskFlowView = memo(function TaskFlowView({ tasks, onAction, curren const levelTasks = depthMap.get(depth) || []; return (
- {/* 层级标签 */}
{depth === 0 ? "顶层任务" : `第 ${depth} 层子任务`}
- - {/* 卡片横向排列 */} -
+
{levelTasks.map((task) => { const isActive = task.status?.toUpperCase() === TASK_STATUS.PENDING || @@ -309,8 +350,6 @@ export const TaskFlowView = memo(function TaskFlowView({ tasks, onAction, curren ); })}
- - {/* 层级间连接箭头 */} {depth < depths.length - 1 && (