refactor: 双模式流转树—焦点模式(活跃+遗留分支) + 全景模式(完整历史时间轴) + 极简卡片
This commit is contained in:
@ -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<TaskResponse>();
|
||||
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 (
|
||||
<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 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" : ""}`}>
|
||||
<div className={`absolute -top-1.5 right-2 rounded px-1.5 py-px text-[8px] font-bold text-white ${main ? "bg-blue-500" : "bg-purple-500"}`}>{main ? "主线" : "分支"}</div>
|
||||
<p className="mt-1 text-xs font-bold text-gray-800 truncate">{task.task_name}</p>
|
||||
<div className="mt-1 flex items-center gap-1">
|
||||
<span className={`rounded-full px-1.5 py-px text-[8px] font-medium ${cfg.bg} ${cfg.text}`}>{cfg.label}</span>
|
||||
<span className="text-[9px] text-gray-400 truncate">{assigneeName || task.assignee_id || "—"}</span>
|
||||
</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>
|
||||
{/* 单行时间 */}
|
||||
<p className="mt-1 text-[8px] text-gray-300">
|
||||
⏰ {fmtTime(task.created_at).split(" ")[0]}
|
||||
{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>}
|
||||
{/* 操作按钮 */}
|
||||
{active && isOwner && (
|
||||
<div className="mt-1.5 flex gap-1 border-t border-gray-100 pt-1.5">
|
||||
{task.status?.toUpperCase() === TASK_STATUS.PENDING && <button onClick={() => onAction({ task, action: "receive" })} className="flex-1 rounded border border-blue-200 bg-blue-50 py-0.5 text-[8px] text-blue-600">接收</button>}
|
||||
{task.status?.toUpperCase() !== TASK_STATUS.PENDING && <button onClick={() => onAction({ task, action: "transfer" })} className="flex-1 rounded border border-green-200 bg-green-50 py-0.5 text-[8px] text-green-600">转交</button>}
|
||||
<button onClick={() => onAction({ task, action: "reject" })} className="flex-1 rounded border border-red-200 bg-red-50 py-0.5 text-[8px] text-red-500">驳回</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 提交记录 */}
|
||||
{active && !isOwner && isManager && (
|
||||
<div className="mt-1.5 flex gap-1 border-t border-orange-100 pt-1.5">
|
||||
<button onClick={() => onAction({ task, action: "reject" })} className="flex-1 rounded border border-orange-200 bg-orange-50 py-0.5 text-[8px] text-orange-600">强制驳回</button>
|
||||
<button onClick={() => onAction({ task, action: "transfer" })} className="flex-1 rounded border border-orange-200 bg-orange-50 py-0.5 text-[8px] text-orange-600">强制转交</button>
|
||||
</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 onClick={(e) => { 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">
|
||||
<FileText className="mr-0.5 inline h-2.5 w-2.5" />{task.records.length}条
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@ -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<string, string>;
|
||||
}
|
||||
|
||||
export const TaskFlowView = memo(function TaskFlowView({ tasks, onAction, currentUser, assigneeNames }: TaskFlowViewProps) {
|
||||
const [showFullMap, setShowFullMap] = useState(false);
|
||||
const [recordsTask, setRecordsTask] = useState<TaskResponse | null>(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 (
|
||||
<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="mb-3 flex justify-center">
|
||||
<button onClick={() => setShowFullMap(!showFullMap)}
|
||||
className="rounded-full bg-gray-100 px-4 py-1.5 text-xs font-medium text-gray-600 hover:bg-gray-200 transition-colors">
|
||||
{showFullMap ? "🔼 收起,仅看当前并发任务" : "👁️ 展开全景流转树 (查看包含已完工在内的完整历史)"}
|
||||
</button>
|
||||
</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)} />
|
||||
|
||||
{/* ─── 焦点模式 ─── */}
|
||||
{!showFullMap && (
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
{/* 顶部行:遗留分支标记 */}
|
||||
{legacySubs.length > 0 && (
|
||||
<div className="mb-2 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 纵向排列 */}
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
{activeMain.map(t => (
|
||||
<SlimCard key={t.id} task={t} assigneeName={assigneeNames?.[t.assignee_id || ""]} active onAction={onAction} currentUser={currentUser} onViewRecords={setRecordsTask} />
|
||||
))}
|
||||
{activeMain.length === 0 && <p className="text-xs text-gray-400 py-4">当前无活跃主线任务</p>}
|
||||
</div>
|
||||
|
||||
{/* 两侧:activeSubs + legacySubs 与中央同行 */}
|
||||
<div className="flex flex-wrap items-start justify-center gap-4 mt-2">
|
||||
{/* activeSubs(实线) */}
|
||||
{activeSubs.map(t => (
|
||||
<div key={t.id} className="flex flex-col items-center">
|
||||
<div className="h-4 w-0.5 bg-gray-300" />
|
||||
<div className="w-8 border-t-2 border-solid border-gray-400" />
|
||||
<SlimCard task={t} assigneeName={assigneeNames?.[t.assignee_id || ""]} active onAction={onAction} currentUser={currentUser} onViewRecords={setRecordsTask} />
|
||||
</div>
|
||||
))}
|
||||
{/* legacySubs(虚线+来源标注) */}
|
||||
{legacySubs.map(t => (
|
||||
<div key={t.id} className="flex flex-col items-center">
|
||||
<div className="h-4 w-0.5 bg-orange-200" />
|
||||
<div className="w-8 border-t-2 border-dashed border-orange-300" />
|
||||
<SlimCard task={t} assigneeName={assigneeNames?.[t.assignee_id || ""]} active legacy onAction={onAction} currentUser={currentUser} onViewRecords={setRecordsTask} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ─── 全景模式 ─── */}
|
||||
{showFullMap && (
|
||||
<div className="space-y-6">
|
||||
{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 (
|
||||
<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" />
|
||||
|
||||
{/* 主干卡片居中 */}
|
||||
<div className="flex justify-center">
|
||||
<div className="relative z-10">
|
||||
<SlimCard task={mainTask} assigneeName={assigneeNames?.[mainTask.assignee_id || ""]}
|
||||
active={active(mainTask.status)} onAction={onAction} currentUser={currentUser} onViewRecords={setRecordsTask} />
|
||||
{active(mainTask.status) && (
|
||||
<div className="absolute -top-1 -left-1 h-3 w-3 rounded-full bg-green-400 border-2 border-white" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 分支水平排列 */}
|
||||
{children.length > 0 && (
|
||||
<div className="flex flex-wrap justify-center gap-3 mt-2">
|
||||
{children.map(c => (
|
||||
<div key={c.id} className="flex flex-col items-center">
|
||||
<div className="w-0.5 h-3 bg-gray-300" />
|
||||
<SlimCard task={c} assigneeName={assigneeNames?.[c.assignee_id || ""]}
|
||||
active={active(c.status)} legacy={hasLegacyActive && !active(mainTask.status)}
|
||||
onAction={onAction} currentUser={currentUser} onViewRecords={setRecordsTask} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 区间连接线 */}
|
||||
{allMains.indexOf(mainTask) < allMains.length - 1 && (
|
||||
<div className="flex justify-center py-2">
|
||||
<span className="text-[10px] text-gray-300">▼</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{allMains.length === 0 && <p className="text-center text-xs text-gray-400 py-8">暂无流转记录</p>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 记录弹窗 */}
|
||||
{recordsTask && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div className="absolute inset-0 bg-black/40 backdrop-blur-sm" onClick={() => setRecordsTask(null)} />
|
||||
<div className="relative z-10 mx-4 max-h-[80vh] w-full max-w-md overflow-y-auto rounded-xl bg-white p-5 shadow-2xl">
|
||||
<div className="mb-3 flex items-center justify-between"><h3 className="text-sm font-bold">提交记录 — {recordsTask.task_name}</h3><button onClick={() => setRecordsTask(null)} className="rounded p-1 text-gray-400 hover:bg-gray-100"><X className="h-4 w-4" /></button></div>
|
||||
{(recordsTask.records || []).length === 0 ? <p className="py-8 text-center text-sm text-gray-400">暂无记录</p> :
|
||||
<div className="space-y-2">{[...recordsTask.records!].reverse().map((r, i) => (
|
||||
<div key={r.id} className="flex gap-2">
|
||||
<div className={`mt-1.5 h-2 w-2 shrink-0 rounded-full ${i === 0 ? "bg-blue-500" : "bg-gray-300"}`} />
|
||||
<div className="flex-1 rounded bg-gray-50 px-3 py-2"><p className="text-[10px] text-gray-400">{fmtTime(r.created_at)}</p>
|
||||
{(r.note || r.remark) && <p className="mt-0.5 text-xs text-gray-700">{r.note || r.remark}</p>}
|
||||
{(() => { const imgs = parseImages((r as any).images); if (!imgs.length) return null; return <div className="mt-1 flex gap-1">{imgs.map((img, j) => <img key={j} src={imageUrl(img)} className="h-12 w-12 rounded border object-cover cursor-pointer" onClick={() => window.open(imageUrl(img))} />)}</div>; })()}
|
||||
</div>
|
||||
</div>
|
||||
))}</div>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user