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

237 lines
14 KiB
TypeScript
Raw Normal View History

/**
* 流转树双模式可视化 — 焦点模式 + 全景模式
*/
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); }
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; }
// ============================================================
// 极简卡片
// ============================================================
const SlimCard = memo(function SlimCard({
task, assigneeName, active, legacy, onAction, currentUser, onViewRecords,
}: {
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 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-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>
{/* 单行时间 */}
<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-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>
);
});
// ============================================================
// 主视图
// ============================================================
interface TaskFlowViewProps {
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);
// 数据分类
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>
{/* 模式切换 */}
<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>
{/* ─── 焦点模式 ─── */}
{!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>
);
});
export default TaskFlowView;