Files
track/frontend/src/components/TaskTree/TaskFlowView.tsx

391 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 任务流转树 — 递归树形渲染,与移动端 TaskSwipeCards 核心逻辑对齐
*
* 渲染规则:
* - 顶层任务 (!parent_task_id) = 主分支(蓝色)
* - TRANSFER/RECOVERY 子任务 = 主干下一环(同缩进,蓝色)
* - SPAWN 子任务 = 分支(缩进 + 紫色 + 左侧连接线)
*/
import { memo, 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())}`;
}
/** 主线判定:与移动端 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();
});
}
// ============================================================
// 停留耗时
// ============================================================
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,
}: {
task: TaskResponse; isActive: boolean;
onAction: (t: ModalTarget) => void;
currentUser?: { username?: string; role?: string } | null;
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() === "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";
return (
<div
className={`relative w-56 shrink-0 rounded-xl border-2 bg-white p-3.5 shadow-md transition-all ${
isActive ? `border-blue-400 ${cfg.ring} shadow-lg shadow-blue-100 scale-105 z-10`
: isCompleted || isArchived ? "border-gray-200 opacity-70"
: isCanceled ? "border-gray-200 opacity-50"
: "border-gray-200 hover:shadow-lg"
} ${task.is_rework ? "border-l-red-500 border-l-4" : ""}`}
>
{/* 角标:主分支(蓝) / 分支(紫) */}
<div className={`absolute top-3 right-3 rounded px-2 py-0.5 text-[9px] font-bold text-white ${main ? "bg-blue-600" : "bg-purple-500"}`}>
{main ? "主分支" : "分支"}
</div>
{/* 返工 / 入库 */}
<div className="mb-1 flex flex-wrap gap-1">
{task.is_rework && <span className="inline-flex items-center rounded bg-red-600 px-1.5 py-0.5 text-[9px] font-bold text-white animate-pulse"><AlertTriangle className="mr-0.5 h-2.5 w-2.5" />返工</span>}
{task.status === "ARCHIVED" && <span className="inline-flex items-center rounded border border-dashed border-purple-300 bg-purple-50 px-1.5 py-0.5 text-[9px] font-bold text-purple-600">📦 入库</span>}
</div>
{/* 类型标签 + 状态 */}
<div className="flex items-center justify-between mt-1">
<span className={`rounded px-2 py-0.5 text-[9px] font-bold ${main ? "bg-blue-600 text-white" : "bg-purple-100 text-purple-700"}`}>
{main ? "主分支" : "分支"}
</span>
{task.child_tasks.length > 1 && (
<span className="inline-flex items-center rounded bg-purple-100 px-1.5 py-0.5 text-[9px] font-medium text-purple-700">
<GitBranch className="mr-0.5 h-2.5 w-2.5" />裂变×{task.child_tasks.length}
</span>
)}
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-medium ${cfg.bg} ${cfg.text}`}>{cfg.label}</span>
</div>
<p className="mt-2 text-base font-extrabold text-gray-800 leading-tight">{task.task_name}</p>
{/* 负责人 */}
<div className="mt-2 flex items-center gap-2 text-[11px]">
<span className="text-gray-400">👤 负责人</span>
<span className="font-semibold text-gray-700">{assigneeName || task.assignee_id || "未分配"}</span>
</div>
{/* 备注 */}
{task.remark && (
<div className="mt-2 rounded-lg border border-yellow-200 bg-yellow-50 px-2.5 py-2">
<p className="text-[10px] font-bold text-yellow-700">📌 备注</p>
<p className="mt-0.5 text-[11px] text-gray-700 leading-relaxed">{task.remark}</p>
</div>
)}
{/* 提交记录 */}
{task.records && task.records.length > 0 && (
<div onClick={(e) => { 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">
<FileText className="h-3.5 w-3.5 text-blue-500" />
<span className="flex-1 text-[11px] font-bold text-blue-600">共 {task.records.length} 条</span>
<span className="text-[10px] text-blue-400">查看 ›</span>
</div>
)}
{/* 耗时 */}
{dwell && (
<p className={`mt-1.5 flex items-center gap-1 text-[10px] ${dwell.highlight ? "text-red-500 font-semibold" : "text-orange-500"}`}>
<Clock className="h-3 w-3" />
{dwell.highlight ? <span className="animate-pulse">⏳ 停留: {dwell.text}</span> : <span>耗时: {dwell.text}</span>}
</p>
)}
{task.reject_reason && <p className="mt-1 text-[10px] text-red-500 line-clamp-2">驳回原因: {task.reject_reason}</p>}
{/* 状态标记 */}
<div className="mt-2 flex flex-wrap gap-1.5">
{task.received_at && <span className="inline-flex items-center gap-1 rounded-md bg-gray-100 px-1.5 py-0.5 text-[9px] font-semibold text-gray-600"><CheckCircle className="h-2.5 w-2.5" />已接收</span>}
{task.completed_at && <span className="inline-flex items-center gap-1 rounded-md bg-green-50 px-1.5 py-0.5 text-[9px] font-semibold text-green-600"><Flag className="h-2.5 w-2.5" />已完工</span>}
</div>
<div className="mt-2 border-t border-gray-100 pt-2">
<p className="text-[9px] text-gray-400">创建: {fmtTime(task.created_at)}</p>
{task.received_at && <p className="text-[9px] text-gray-400">接收: {fmtTime(task.received_at)}</p>}
{task.completed_at && <p className="text-[9px] text-gray-400">完工: {fmtTime(task.completed_at)}</p>}
</div>
{/* 权限按钮 — 严格互斥 */}
{isActive && isOwner && (
<div className="mt-2 flex gap-1.5 border-t border-gray-100 pt-2">
{task.status?.toUpperCase() === TASK_STATUS.PENDING && (<>
<button onClick={() => onAction({ task, action: "receive" })} className="flex-1 rounded border border-blue-200 bg-blue-50 py-1 text-[10px] font-medium text-blue-600 hover:bg-blue-100">接收</button>
<button onClick={() => onAction({ task, action: "reject" })} className="rounded border border-red-200 bg-red-50 px-2 py-1 text-[10px] font-medium text-red-500 hover:bg-red-100">驳回</button>
<button onClick={() => onAction({ task, action: "transfer" })} className="rounded border border-green-200 bg-green-50 px-2 py-1 text-[10px] font-medium text-green-600 hover:bg-green-100">转交</button>
</>)}
{task.status?.toUpperCase() === TASK_STATUS.WIP && (<>
<button onClick={() => onAction({ task, action: "reject" })} className="flex-1 rounded border border-red-200 bg-red-50 py-1 text-[10px] font-medium text-red-500 hover:bg-red-100">驳回</button>
<button onClick={() => onAction({ task, action: "transfer" })} className="flex-1 rounded border border-green-200 bg-green-50 py-1 text-[10px] font-medium text-green-600 hover:bg-green-100">转交</button>
</>)}
</div>
)}
{isActive && !isOwner && isManager && (
<div className="mt-2 border-t border-orange-100 pt-2">
<p className="mb-1 text-[9px] text-orange-500">⚠ 运维干预</p>
<div className="flex gap-1.5">
<button onClick={() => onAction({ task, action: "reject" })} className="flex-1 rounded border border-orange-200 bg-orange-50 py-1 text-[10px] font-medium text-orange-600 hover:bg-orange-100">强制驳回</button>
<button onClick={() => onAction({ task, action: "transfer" })} className="flex-1 rounded border border-orange-200 bg-orange-50 py-1 text-[10px] font-medium text-orange-600 hover:bg-orange-100">强制转交</button>
</div>
</div>
)}
{isActive && !isOwner && !isManager && (
<div className="mt-2 border-t border-gray-100 pt-2">
<p className="text-center text-[9px] text-gray-400">非当前任务责任人</p>
</div>
)}
</div>
);
});
// ============================================================
// 记录弹窗
// ============================================================
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 (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/40 backdrop-blur-sm" onClick={onClose} />
<div className="relative z-10 mx-4 w-full max-w-md max-h-[80vh] overflow-y-auto rounded-xl bg-white shadow-2xl">
<div className="sticky top-0 z-10 flex items-center justify-between border-b border-gray-100 bg-white px-5 py-4 rounded-t-xl">
<div><h3 className="text-base font-bold text-gray-800">提交记录</h3><p className="text-xs text-gray-400">{task.task_name}</p></div>
<button onClick={onClose} className="rounded-lg p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600"><X className="h-5 w-5" /></button>
</div>
<div className="px-5 py-3">
{records.length === 0 ? (
<div className="flex flex-col items-center py-12 text-gray-400"><span className="text-4xl mb-2">📭</span><p className="text-sm">暂无历史记录</p></div>
) : (
<div className="space-y-3">
{[...records].reverse().map((rec, i) => (
<div key={rec.id} className="relative pl-6">
<div className="absolute left-2 top-0 bottom-0 flex flex-col items-center">
<div className={`h-3 w-3 rounded-full ${i === 0 ? "bg-blue-500 shadow-[0_0_0_4px_rgba(37,99,235,0.15)]" : "bg-gray-300"}`} />
{i < records.length - 1 && <div className="flex-1 w-0.5 bg-gray-200 min-h-[12px]" />}
</div>
<div className="rounded-lg bg-gray-50 px-3 py-2.5">
<p className="text-[11px] text-gray-400">{fmtTime(rec.created_at)}</p>
{rec.note && <p className="mt-1 text-sm text-gray-700 leading-relaxed">{rec.note}</p>}
{rec.remark && <p className="mt-1 text-sm text-gray-700 leading-relaxed">{rec.remark}</p>}
{(() => {
const imgs = parseImages((rec as any).images);
if (!imgs.length) return null;
return <div className="mt-2 flex gap-1.5 flex-wrap">{imgs.map((img, j) => <img key={j} src={imageUrl(img)} alt="" className="h-16 w-16 rounded-lg border border-gray-200 object-cover cursor-pointer hover:opacity-80" onClick={() => window.open(imageUrl(img), "_blank")} />)}</div>;
})()}
</div>
</div>
))}
</div>
)}
</div>
</div>
</div>
);
});
// ============================================================
// 🚀 递归任务节点 — 替代 flattenLevels
// ============================================================
interface CommonProps {
onAction: (t: ModalTarget) => void;
currentUser?: { username?: string; role?: string } | null;
assigneeNames?: Record<string, string>;
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 (
<div className="w-56 shrink-0 rounded-xl border-2 border-red-300 bg-red-50 p-3 text-center">
<p className="text-[10px] font-bold text-red-500">⚠ 树深度超限</p>
<p className="mt-1 text-[9px] text-red-400">请检查数据完整性</p>
</div>
);
}
const isActive = task.status?.toUpperCase() === TASK_STATUS.PENDING
|| task.status?.toUpperCase() === TASK_STATUS.WIP;
if (!task.child_tasks || task.child_tasks.length === 0) {
return (
<TaskCard task={task} isActive={isActive} onAction={common.onAction}
currentUser={common.currentUser}
assigneeName={common.assigneeNames?.[task.assignee_id || ""]}
isBranch={isBranch}
onViewRecords={common.onViewRecords} />
);
}
// 分子任务:TRANSFER = 主干(同缩进),SPAWN = 分支(缩进)
const sorted = sortTasks(task.child_tasks);
const mainChildren = sorted.filter(c => isMainTask(c));
const spawnChildren = sorted.filter(c => !isMainTask(c));
return (
<div className="relative">
{/* 主干连接线 */}
{mainChildren.length > 0 && (
<div className="absolute left-[108px] top-full w-0.5 bg-gray-200 z-0" style={{ height: "1.5rem" }} />
)}
{/* 当前节点卡片 */}
<TaskCard task={task} isActive={isActive} onAction={common.onAction}
currentUser={common.currentUser}
assigneeName={common.assigneeNames?.[task.assignee_id || ""]}
isBranch={isBranch}
onViewRecords={common.onViewRecords} />
{/* TRANSFER 子任务:主干下一环,同缩进,带竖线连接 */}
{mainChildren.length > 0 && (
<div className="relative ml-0 mt-1 pl-0">
{/* 竖线 */}
<div className="absolute left-[108px] top-0 bottom-0 w-0.5 bg-gray-200" />
<div className="flex flex-col gap-2">
{mainChildren.map((child) => (
<div key={child.id} className="relative ml-0 pl-0">
{/* 横线连接 */}
<div className="absolute left-[108px] top-6 w-6 h-0.5 bg-gray-200" />
<div className="ml-6">
<TaskNode task={child} isBranch={false} common={common} depth={depth + 1} />
</div>
</div>
))}
</div>
</div>
)}
{/* SPAWN 子任务:分支,缩进 + 紫色左边线 */}
{spawnChildren.length > 0 && (
<div className="ml-6 mt-2 border-l-2 border-purple-200 pl-4">
{spawnChildren.map((child, idx) => (
<div key={child.id} className={idx > 0 ? "mt-2" : ""}>
<TaskNode task={child} isBranch={true} common={common} />
</div>
))}
</div>
)}
</div>
);
});
// ============================================================
// 主视图
// ============================================================
interface TaskFlowViewProps {
tasks: TaskResponse[];
onAction: (target: ModalTarget) => void;
currentUser?: { username?: string; role?: string } | null;
assigneeNames?: Record<string, string>;
}
export const TaskFlowView = memo(function TaskFlowView({ tasks, onAction, currentUser, assigneeNames }: TaskFlowViewProps) {
const [recordsTask, setRecordsTask] = useState<TaskResponse | null>(null);
if (!tasks || tasks.length === 0) return null;
const common: CommonProps = { onAction, currentUser, assigneeNames, onViewRecords: setRecordsTask };
return (
<div className="space-y-3">
<div className="mb-2 flex items-center gap-2">
<span className="text-[10px] font-semibold text-gray-400 uppercase tracking-wider">流转树</span>
<div className="h-px flex-1 bg-gray-100" />
</div>
<div className="flex flex-col gap-3 overflow-x-auto pb-2">
{sortTasks(tasks).map((task) => (
<TaskNode key={task.id} task={task} isBranch={false} common={common} />
))}
</div>
<RecordsModal open={!!recordsTask} task={recordsTask} onClose={() => setRecordsTask(null)} />
</div>
);
});
export default TaskFlowView;