feat(任务全景): 流转树改为宽屏弹窗并放大字体
- TaskFlowView 增加 size prop(sm默认/lg放大),弹窗模式卡片/字体/按钮全部放大 - TaskTreeViewer 通用 Modal 导出并支持 widthClass/bodyClassName 定制 - AdminTasksPage 内嵌展开改为宽屏 Modal(max-w-6xl),保留接收/转交/驳回操作 - 记录弹窗放大且 z-index 提升到 z-[60] 保证覆盖弹窗 - 修复 TaskRecordResponse 无 note 字段的类型错误,清理未使用 import
This commit is contained in:
@ -2,7 +2,7 @@
|
||||
* 流转树双模式可视化 — 焦点模式 + 全景模式
|
||||
*/
|
||||
import { memo, useMemo, useState } from "react";
|
||||
import { GitBranch, AlertTriangle, Clock, CheckCircle, Flag, FileText, X } from "lucide-react";
|
||||
import { FileText, X } from "lucide-react";
|
||||
import type { TaskResponse } from "../../types/api";
|
||||
import { TASK_STATUS } from "../../types/api";
|
||||
import { getStatusConfig } from "../../constants/task";
|
||||
@ -15,12 +15,12 @@ function fmtTime(d: string | null) { if (!d) return ""; const dt = new Date(d);
|
||||
function isMain(t: TaskResponse) { return !t.parent_task_id || t.task_type === "TRANSFER" || t.task_type === "RECOVERY"; }
|
||||
function active(s: string) { return s === "WIP" || s === "PENDING"; }
|
||||
/** 微型右箭头 SVG */
|
||||
function ArrowRight({ color = "#9ca3af" }: { color?: string }) {
|
||||
return <svg className="h-3 w-3 shrink-0" viewBox="0 0 8 8"><polygon points="0,0 8,4 0,8" fill={color} /></svg>;
|
||||
function ArrowRight({ color = "#9ca3af", big }: { color?: string; big?: boolean }) {
|
||||
return <svg className={`shrink-0 ${big ? "h-4 w-4" : "h-3 w-3"}`} viewBox="0 0 8 8"><polygon points="0,0 8,4 0,8" fill={color} /></svg>;
|
||||
}
|
||||
/** 微型左箭头 SVG */
|
||||
function ArrowLeft({ color = "#9ca3af" }: { color?: string }) {
|
||||
return <svg className="h-3 w-3 shrink-0" viewBox="0 0 8 8"><polygon points="8,0 0,4 8,8" fill={color} /></svg>;
|
||||
function ArrowLeft({ color = "#9ca3af", big }: { color?: string; big?: boolean }) {
|
||||
return <svg className={`shrink-0 ${big ? "h-4 w-4" : "h-3 w-3"}`} viewBox="0 0 8 8"><polygon points="8,0 0,4 8,8" fill={color} /></svg>;
|
||||
}
|
||||
function parseImages(s: any): string[] {
|
||||
if (!s) return [];
|
||||
@ -48,56 +48,92 @@ 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; }
|
||||
|
||||
// ============================================================
|
||||
// 尺寸映射表(sm=现状小卡片,lg=弹窗放大)
|
||||
// ============================================================
|
||||
const SIZE_MAP = {
|
||||
sm: {
|
||||
card: "w-44 p-2.5 shadow-sm",
|
||||
badge: "-top-1.5 right-2 px-1.5 py-px text-[8px]",
|
||||
title: "mt-1 text-xs",
|
||||
metaRow: "mt-1",
|
||||
status: "px-1.5 py-px text-[8px]",
|
||||
assignee: "text-[9px]",
|
||||
time: "mt-1 text-[8px]",
|
||||
sub: "mt-1 text-[8px]",
|
||||
btnRow: "mt-1.5 pt-1.5",
|
||||
btn: "py-0.5 text-[8px]",
|
||||
record: "mt-1 px-1.5 py-0.5 text-[8px]",
|
||||
recordIcon: "h-2.5 w-2.5",
|
||||
},
|
||||
lg: {
|
||||
card: "w-72 p-4 shadow-md",
|
||||
badge: "-top-2 right-3 px-2 py-0.5 text-xs",
|
||||
title: "mt-1.5 text-base",
|
||||
metaRow: "mt-2",
|
||||
status: "px-2 py-0.5 text-xs",
|
||||
assignee: "text-sm",
|
||||
time: "mt-2 text-xs",
|
||||
sub: "mt-1.5 text-xs",
|
||||
btnRow: "mt-2.5 pt-2.5",
|
||||
btn: "py-1.5 text-sm",
|
||||
record: "mt-2 px-2.5 py-1 text-xs",
|
||||
recordIcon: "h-4 w-4",
|
||||
},
|
||||
} as const;
|
||||
|
||||
// ============================================================
|
||||
// 极简卡片
|
||||
// ============================================================
|
||||
const SlimCard = memo(function SlimCard({
|
||||
task, assigneeName, active, legacy, onAction, currentUser, onViewRecords, rootMainId,
|
||||
task, assigneeName, active, legacy, onAction, currentUser, onViewRecords, rootMainId, size = "sm",
|
||||
}: {
|
||||
task: TaskResponse; assigneeName?: string; active: boolean; legacy?: boolean;
|
||||
onAction: (t: ModalTarget) => void; currentUser?: { username?: string; role?: string } | null;
|
||||
onViewRecords?: (t: TaskResponse) => void;
|
||||
rootMainId?: string;
|
||||
size?: "sm" | "lg";
|
||||
}) {
|
||||
const cfg = getStatusConfig(task.status);
|
||||
const sz = SIZE_MAP[size];
|
||||
const isOwner = !!(currentUser?.username && task.assignee_id === currentUser.username);
|
||||
const isManager = currentUser?.role === "SUPER_ADMIN" || currentUser?.role === "SUPERVISOR";
|
||||
const main = isMain(task);
|
||||
const isNestedSpawn = !main && rootMainId && task.parent_task_id !== rootMainId && !!task.parent_task_id;
|
||||
|
||||
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 className={`relative shrink-0 rounded-lg border bg-white ${sz.card} ${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 ${sz.badge} rounded font-bold text-white ${main ? "bg-blue-500" : "bg-purple-500"}`}>{main ? "主线" : "分支"}</div>
|
||||
<p className={`${sz.title} font-bold text-gray-800 truncate`}>{task.task_name}</p>
|
||||
<div className={`${sz.metaRow} flex items-center gap-1`}>
|
||||
<span className={`rounded-full ${sz.status} font-medium ${cfg.bg} ${cfg.text}`}>{cfg.label}</span>
|
||||
<span className={`${sz.assignee} text-gray-400 truncate`}>{assigneeName || task.assignee_id || "—"}</span>
|
||||
</div>
|
||||
{/* 单行时间 */}
|
||||
<p className="mt-1 text-[8px] text-gray-300">
|
||||
<p className={`${sz.time} 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>}
|
||||
{isNestedSpawn && <p className="mt-1 text-[8px] text-purple-500">协助: {findParent(task)?.assignee_id || "—"}</p>}
|
||||
{legacy && <p className={`${sz.sub} text-orange-500`}>源自: {assigneeName || (findParent(task)?.assignee_id) || "历史任务"}</p>}
|
||||
{isNestedSpawn && <p className={`${sz.sub} text-purple-500`}>协助: {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 className={`${sz.btnRow} flex gap-1 border-t border-gray-100`}>
|
||||
{task.status?.toUpperCase() === TASK_STATUS.PENDING && <button onClick={() => onAction({ task, action: "receive" })} className={`flex-1 rounded border border-blue-200 bg-blue-50 ${sz.btn} 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 ${sz.btn} text-green-600`}>转交</button>}
|
||||
<button onClick={() => onAction({ task, action: "reject" })} className={`flex-1 rounded border border-red-200 bg-red-50 ${sz.btn} 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 className={`${sz.btnRow} flex gap-1 border-t border-orange-100`}>
|
||||
<button onClick={() => onAction({ task, action: "reject" })} className={`flex-1 rounded border border-orange-200 bg-orange-50 ${sz.btn} text-orange-600`}>强制驳回</button>
|
||||
<button onClick={() => onAction({ task, action: "transfer" })} className={`flex-1 rounded border border-orange-200 bg-orange-50 ${sz.btn} 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 onClick={(e) => { e.stopPropagation(); onViewRecords?.(task); }} className={`${sz.record} cursor-pointer rounded bg-blue-50 text-blue-600 hover:bg-blue-100`}>
|
||||
<FileText className={`mr-0.5 inline ${sz.recordIcon}`} />{task.records.length}条
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@ -111,9 +147,10 @@ interface TaskFlowViewProps {
|
||||
tasks: TaskResponse[]; onAction: (t: ModalTarget) => void;
|
||||
currentUser?: { username?: string; role?: string } | null;
|
||||
assigneeNames?: Record<string, string>;
|
||||
size?: "sm" | "lg";
|
||||
}
|
||||
|
||||
export const TaskFlowView = memo(function TaskFlowView({ tasks, onAction, currentUser, assigneeNames }: TaskFlowViewProps) {
|
||||
export const TaskFlowView = memo(function TaskFlowView({ tasks, onAction, currentUser, assigneeNames, size = "sm" }: TaskFlowViewProps) {
|
||||
const [showFullMap, setShowFullMap] = useState(false);
|
||||
const [recordsTask, setRecordsTask] = useState<TaskResponse | null>(null);
|
||||
|
||||
@ -139,12 +176,13 @@ export const TaskFlowView = memo(function TaskFlowView({ tasks, onAction, curren
|
||||
}, [tasks]);
|
||||
|
||||
// 🚀 递归渲染分支节点 — 每个节点从自己的 childMap 获取直系子孙,保持树结构不断裂
|
||||
const renderBranch = (node: TaskResponse, side: 'left' | 'right', isLegacy: boolean, rootMainId: string): JSX.Element => {
|
||||
const renderBranch = (node: TaskResponse, side: 'left' | 'right', isLegacy: boolean, rootMainId: string): React.JSX.Element => {
|
||||
const kids = childMap[node.id] || [];
|
||||
const big = size === "lg";
|
||||
const arrow = side === 'left'
|
||||
? (<div className="flex items-center"><ArrowLeft color={isLegacy ? "#fdba74" : "#9ca3af"} /><div className={`w-6 border-t-2 ${isLegacy ? "border-dashed border-orange-300" : "border-solid border-gray-400"}`} /></div>)
|
||||
: (<div className="flex items-center"><div className={`w-6 border-t-2 ${isLegacy ? "border-dashed border-orange-300" : "border-solid border-gray-400"}`} /><ArrowRight color={isLegacy ? "#fdba74" : "#9ca3af"} /></div>);
|
||||
const card = <SlimCard task={node} active={active(node.status)} legacy={isLegacy} assigneeName={assigneeNames?.[node.assignee_id || ""]} onAction={onAction} currentUser={currentUser} onViewRecords={setRecordsTask} rootMainId={rootMainId} />;
|
||||
? (<div className="flex items-center"><ArrowLeft color={isLegacy ? "#fdba74" : "#9ca3af"} big={big} /><div className={`${big ? "w-10 border-t-[3px]" : "w-6 border-t-2"} ${isLegacy ? "border-dashed border-orange-300" : "border-solid border-gray-400"}`} /></div>)
|
||||
: (<div className="flex items-center"><div className={`${big ? "w-10 border-t-[3px]" : "w-6 border-t-2"} ${isLegacy ? "border-dashed border-orange-300" : "border-solid border-gray-400"}`} /><ArrowRight color={isLegacy ? "#fdba74" : "#9ca3af"} big={big} /></div>);
|
||||
const card = <SlimCard task={node} active={active(node.status)} legacy={isLegacy} assigneeName={assigneeNames?.[node.assignee_id || ""]} onAction={onAction} currentUser={currentUser} onViewRecords={setRecordsTask} rootMainId={rootMainId} size={size} />;
|
||||
const kidsContainer = kids.length > 0 ? (
|
||||
<div className={`flex flex-col gap-2 ${side === 'left' ? 'items-end' : 'items-start'}`}>
|
||||
{kids.map(k => renderBranch(k, side, isLegacy, rootMainId))}
|
||||
@ -178,7 +216,7 @@ export const TaskFlowView = memo(function TaskFlowView({ tasks, onAction, curren
|
||||
{/* 模式切换 */}
|
||||
<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">
|
||||
className={`rounded-full bg-gray-100 font-medium text-gray-600 hover:bg-gray-200 transition-colors ${size === "lg" ? "px-5 py-2 text-sm" : "px-4 py-1.5 text-xs"}`}>
|
||||
{showFullMap ? "🔼 收起,仅看当前并发任务" : "👁️ 展开全景流转树 (查看包含已完工在内的完整历史)"}
|
||||
</button>
|
||||
</div>
|
||||
@ -195,7 +233,7 @@ export const TaskFlowView = memo(function TaskFlowView({ tasks, onAction, curren
|
||||
|
||||
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 z-0" />
|
||||
<div className={`absolute left-1/2 top-0 bottom-0 -translate-x-1/2 z-0 bg-gray-200 ${size === "lg" ? "w-1" : "w-0.5"}`} />
|
||||
<div className="flex flex-row items-start w-full">
|
||||
{/* 左翼 — 递归渲染,子子孙孙向外延伸 */}
|
||||
<div className="flex-1 flex flex-col items-end justify-center gap-2 pr-2">
|
||||
@ -204,9 +242,9 @@ export const TaskFlowView = memo(function TaskFlowView({ tasks, onAction, curren
|
||||
{/* 中央 */}
|
||||
<div className="shrink-0 z-10 relative">
|
||||
<SlimCard task={mainTask} active={active(mainTask.status)}
|
||||
assigneeName={assigneeNames?.[mainTask.assignee_id || ""]} onAction={onAction} currentUser={currentUser} onViewRecords={setRecordsTask} />
|
||||
assigneeName={assigneeNames?.[mainTask.assignee_id || ""]} onAction={onAction} currentUser={currentUser} onViewRecords={setRecordsTask} size={size} />
|
||||
{active(mainTask.status) && (
|
||||
<div className="absolute -top-1 -left-1 h-3 w-3 rounded-full bg-green-400 border-2 border-white" />
|
||||
<div className={`absolute -top-1 -left-1 rounded-full bg-green-400 border-2 border-white ${size === "lg" ? "h-4 w-4" : "h-3 w-3"}`} />
|
||||
)}
|
||||
</div>
|
||||
{/* 右翼 — 递归渲染,子子孙孙向外延伸 */}
|
||||
@ -217,14 +255,14 @@ export const TaskFlowView = memo(function TaskFlowView({ tasks, onAction, curren
|
||||
|
||||
{visibleMains.indexOf(mainTask) < visibleMains.length - 1 && (
|
||||
<div className="flex justify-center py-2">
|
||||
<span className="text-[10px] text-gray-300">▼</span>
|
||||
<span className={`text-gray-300 ${size === "lg" ? "text-sm" : "text-[10px]"}`}>▼</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{visibleMains.length === 0 && (
|
||||
<p className="text-center text-xs text-gray-400 py-8">
|
||||
<p className={`text-center text-gray-400 py-8 ${size === "lg" ? "text-sm" : "text-xs"}`}>
|
||||
{showFullMap ? "暂无流转记录" : "当前无活跃主线任务"}
|
||||
</p>
|
||||
)}
|
||||
@ -232,17 +270,17 @@ export const TaskFlowView = memo(function TaskFlowView({ tasks, onAction, curren
|
||||
|
||||
{/* 记录弹窗 */}
|
||||
{recordsTask && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div className="fixed inset-0 z-[60] 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>
|
||||
<div className={`relative z-10 mx-4 max-h-[80vh] w-full overflow-y-auto rounded-xl bg-white shadow-2xl ${size === "lg" ? "max-w-2xl p-6" : "max-w-md p-5"}`}>
|
||||
<div className={`mb-3 flex items-center justify-between`}><h3 className={`font-bold ${size === "lg" ? "text-lg" : "text-sm"}`}>提交记录 — {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.images); if (!imgs.length) return null; return <div className="mt-1 flex gap-1 flex-wrap">{imgs.map((img, j) => <img key={j} src={imageUrl(img)} className="h-14 w-14 rounded border object-cover cursor-pointer hover:opacity-80 transition-opacity" onClick={() => window.open(imageUrl(img))} />)}</div>; })()}
|
||||
<div className="flex-1 rounded bg-gray-50 px-3 py-2"><p className={`text-gray-400 ${size === "lg" ? "text-xs" : "text-[10px]"}`}>{fmtTime(r.created_at)}</p>
|
||||
{r.remark && <p className={`mt-0.5 text-gray-700 ${size === "lg" ? "text-sm" : "text-xs"}`}>{r.remark}</p>}
|
||||
{(() => { const imgs = parseImages(r.images); if (!imgs.length) return null; return <div className="mt-1 flex gap-1 flex-wrap">{imgs.map((img, j) => <img key={j} src={imageUrl(img)} className={`rounded border object-cover cursor-pointer hover:opacity-80 transition-opacity ${size === "lg" ? "h-24 w-24" : "h-14 w-14"}`} onClick={() => window.open(imageUrl(img))} />)}</div>; })()}
|
||||
</div>
|
||||
</div>
|
||||
))}</div>}
|
||||
|
||||
@ -25,16 +25,20 @@ import { getStatusConfig } from "../../constants/task";
|
||||
// 通用 Modal 容器
|
||||
// ============================================================
|
||||
|
||||
const Modal = memo(function Modal({
|
||||
export const Modal = memo(function Modal({
|
||||
open,
|
||||
onClose,
|
||||
title,
|
||||
children,
|
||||
widthClass = "max-w-md",
|
||||
bodyClassName = "",
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
widthClass?: string;
|
||||
bodyClassName?: string;
|
||||
}) {
|
||||
if (!open) return null;
|
||||
|
||||
@ -46,7 +50,7 @@ const Modal = memo(function Modal({
|
||||
onClick={onClose}
|
||||
/>
|
||||
{/* 弹窗 */}
|
||||
<div className="relative z-10 mx-4 w-full max-w-md rounded-xl bg-white p-6 shadow-2xl">
|
||||
<div className={`relative z-10 mx-4 w-full ${widthClass} rounded-xl bg-white p-6 shadow-2xl ${bodyClassName}`}>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h3 className="text-base font-bold text-gray-800">{title}</h3>
|
||||
<button
|
||||
|
||||
@ -13,7 +13,7 @@ import {
|
||||
} from "../../services/taskApi";
|
||||
import { TaskFlowView } from "../../components/TaskTree/TaskFlowView";
|
||||
import type { ModalTarget } from "../../components/TaskTree/TaskTreeViewer";
|
||||
import { ReceiveConfirmModal, RejectModal, TransferModal } from "../../components/TaskTree/TaskTreeViewer";
|
||||
import { Modal, ReceiveConfirmModal, RejectModal, TransferModal } from "../../components/TaskTree/TaskTreeViewer";
|
||||
import type { ProductResponse } from "../../types/admin";
|
||||
import type { ProductScanResponse } from "../../types/api";
|
||||
import { useToast } from "../../components/ui/Toast";
|
||||
@ -174,12 +174,11 @@ export default function AdminTasksPage() {
|
||||
{
|
||||
key: "actions", label: "操作", colSpan: 2,
|
||||
render: (p) => {
|
||||
const productExpanded = activeTreeProductId === p.serial_number;
|
||||
const isTreeLoading = treeLoading[p.serial_number];
|
||||
return (
|
||||
<button onClick={() => toggleProductTree(p.serial_number)} className="flex items-center gap-1 rounded border border-blue-200 px-2.5 py-1 text-xs font-medium text-blue-600 hover:bg-blue-50 transition-colors">
|
||||
{isTreeLoading ? <Loader2 className="h-3 w-3 animate-spin" /> : productExpanded ? <X className="h-3 w-3" /> : <GitBranch className="h-3 w-3" />}
|
||||
{productExpanded ? "收起" : "流转树"}
|
||||
<button onClick={() => openTreeModal(p.serial_number)} className="flex items-center gap-1 rounded border border-blue-200 px-2.5 py-1 text-xs font-medium text-blue-600 hover:bg-blue-50 transition-colors">
|
||||
{isTreeLoading ? <Loader2 className="h-3 w-3 animate-spin" /> : <GitBranch className="h-3 w-3" />}
|
||||
流转树
|
||||
</button>
|
||||
);
|
||||
},
|
||||
@ -221,9 +220,9 @@ export default function AdminTasksPage() {
|
||||
if (autoSn) {
|
||||
setKeyword(autoSn);
|
||||
loadProducts(autoSn).then((data) => {
|
||||
// 产品加载完成后自动展开流转树
|
||||
// 产品加载完成后自动打开流转树弹窗
|
||||
const found = data.find((p: ProductResponse) => p.serial_number === autoSn);
|
||||
if (found) toggleProductTree(found.serial_number);
|
||||
if (found) openTreeModal(found.serial_number);
|
||||
});
|
||||
} else {
|
||||
loadProducts(keyword);
|
||||
@ -234,6 +233,7 @@ export default function AdminTasksPage() {
|
||||
e?.preventDefault();
|
||||
setExpandedOrders(new Set());
|
||||
setTaskTrees({});
|
||||
setActiveTreeProductId(null);
|
||||
loadProducts(keyword);
|
||||
}
|
||||
|
||||
@ -362,14 +362,8 @@ export default function AdminTasksPage() {
|
||||
});
|
||||
}
|
||||
|
||||
// ---- 排他展开(手风琴模式) ----
|
||||
async function toggleProductTree(serialNumber: string) {
|
||||
// 点击已展开的树 → 收起
|
||||
if (activeTreeProductId === serialNumber) {
|
||||
setActiveTreeProductId(null);
|
||||
return;
|
||||
}
|
||||
// 展开新的 → 自动收起旧的
|
||||
// ---- 流转树宽屏弹窗(懒加载,缓存到 taskTrees) ----
|
||||
function openTreeModal(serialNumber: string) {
|
||||
setActiveTreeProductId(serialNumber);
|
||||
if (!taskTrees[serialNumber]) {
|
||||
setTreeLoading((s) => ({ ...s, [serialNumber]: true }));
|
||||
@ -379,6 +373,9 @@ export default function AdminTasksPage() {
|
||||
.finally(() => setTreeLoading((s) => ({ ...s, [serialNumber]: false })));
|
||||
}
|
||||
}
|
||||
function closeTreeModal() {
|
||||
setActiveTreeProductId(null);
|
||||
}
|
||||
|
||||
// ---- 任务操作 ----
|
||||
const refreshProductTree = useCallback(async (serialNumber: string) => {
|
||||
@ -695,10 +692,6 @@ export default function AdminTasksPage() {
|
||||
|
||||
{/* 产品行 */}
|
||||
{group.products.map((p) => {
|
||||
const productExpanded = activeTreeProductId === p.serial_number;
|
||||
const isTreeLoading = treeLoading[p.serial_number];
|
||||
const tree = taskTrees[p.serial_number];
|
||||
|
||||
return (
|
||||
<div key={p.id}>
|
||||
<div className="grid gap-2 border-t border-gray-50 px-5 py-3 items-center text-sm hover:bg-gray-50/50" style={{ gridTemplateColumns: `repeat(${TOTAL_SPAN}, minmax(0, 1fr))` }}>
|
||||
@ -708,36 +701,6 @@ export default function AdminTasksPage() {
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 展开的流转树 — 卡片堆叠视图 */}
|
||||
{productExpanded && tree && (
|
||||
<div className="border-t border-dashed border-blue-100 bg-gradient-to-b from-blue-50/40 to-white px-5 py-4">
|
||||
<h4 className="mb-3 flex items-center gap-2 text-xs font-semibold text-gray-500">
|
||||
<GitBranch className="h-3.5 w-3.5" />
|
||||
流转卡片 — {p.serial_number}
|
||||
</h4>
|
||||
{tree.task_tree && tree.task_tree.length > 0 ? (
|
||||
<TaskFlowView
|
||||
tasks={tree.task_tree}
|
||||
onAction={setModalTarget}
|
||||
currentUser={currentUser}
|
||||
assigneeNames={tree.assignee_names}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-gray-400">
|
||||
<GitBranch className="mb-3 h-10 w-10 text-gray-300" />
|
||||
<p className="text-sm font-medium text-gray-500">暂无流转记录</p>
|
||||
<p className="mt-1 text-xs text-gray-400">产品刚创建,尚未分配生产任务</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{productExpanded && isTreeLoading && (
|
||||
<div className="border-t border-dashed border-gray-100 bg-gray-50/50 px-5 py-12 text-center">
|
||||
<Loader2 className="mx-auto h-6 w-6 animate-spin text-blue-400" />
|
||||
<p className="mt-2 text-xs text-gray-400">加载流转树...</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@ -762,6 +725,37 @@ export default function AdminTasksPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 🔧 流转树宽屏弹窗 */}
|
||||
{activeTreeProductId && (
|
||||
<Modal
|
||||
open
|
||||
onClose={closeTreeModal}
|
||||
title={`🔀 流转树 — ${activeTreeProductId}`}
|
||||
widthClass="max-w-6xl"
|
||||
bodyClassName="max-h-[90vh] overflow-y-auto"
|
||||
>
|
||||
{treeLoading[activeTreeProductId] ? (
|
||||
<div className="flex justify-center py-16">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-blue-400" />
|
||||
</div>
|
||||
) : taskTrees[activeTreeProductId]?.task_tree?.length ? (
|
||||
<TaskFlowView
|
||||
tasks={taskTrees[activeTreeProductId].task_tree}
|
||||
onAction={setModalTarget}
|
||||
currentUser={currentUser}
|
||||
assigneeNames={taskTrees[activeTreeProductId].assignee_names}
|
||||
size="lg"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-gray-400">
|
||||
<GitBranch className="mb-3 h-12 w-12 text-gray-300" />
|
||||
<p className="text-sm font-medium text-gray-500">暂无流转记录</p>
|
||||
<p className="mt-1 text-xs text-gray-400">产品刚创建,尚未分配生产任务</p>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{/* ---- 弹窗 ---- */}
|
||||
<ReceiveConfirmModal
|
||||
open={modalTarget?.action === "receive"}
|
||||
|
||||
Reference in New Issue
Block a user