소스 검색

修复流程设计器磁吸效果和3d可视化效果

pm 3 달 전
부모
커밋
180a06b173

+ 2 - 2
.env

@@ -4,8 +4,8 @@ VITE_APP_TITLE=能量隔离系统
 # 项目本地运行端口号
 VITE_PORT=81
 # 请求路径
-VITE_BASE_URL='http://192.168.0.10:48080'
-# VITE_BASE_URL='http://120.27.232.27:9292'
+# VITE_BASE_URL='http://192.168.0.10:48080'
+VITE_BASE_URL='http://120.27.232.27:9292'
 # open 运行 npm run dev 时自动打开浏览器
 VITE_OPEN=true
 

BIN
src/assets/key.png


BIN
src/assets/lock.png


+ 120 - 45
src/components/ProcessDesigner.tsx

@@ -21,6 +21,7 @@ import ReactFlow, {
   getBezierPath,
   getSmoothStepPath,
   EdgeTypes,
+  type NodeChange,
 } from 'reactflow';
 import 'reactflow/dist/style.css';
 import {
@@ -904,13 +905,15 @@ export default function ProcessDesigner() {
     sessionStorage.setItem('lastActiveMenu', JSON.stringify(menuInfo));
     navigate('/dashboard');
   };
-  const [nodes, setNodes, onNodesChange] = useNodesState([]);
+  const [nodes, setNodes, onNodesChangeBase] = useNodesState([]);
   const [edges, setEdges, onEdgesChange] = useEdgesState([]);
   
   // 保存初始状态,用于检测是否有未保存的更改
   const initialNodesRef = useRef<Node[]>([]);
   const initialEdgesRef = useRef<Edge[]>([]);
   const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
+  // 是否正处于「从服务器/缓存加载或恢复」阶段,此期间不把 onNodesChange/onEdgesChange 视为用户修改
+  const isLoadingFromServerOrRestoreRef = useRef(false);
   
   // 从URL参数获取流程ID
   const workflowId = React.useMemo(() => {
@@ -1202,24 +1205,12 @@ export default function ProcessDesigner() {
     console.log('Edges 状态更新:', edges.length, edges);
   }, [edges]);
 
-  // 监听 nodes 和 edges 变化,实时保存到缓存,并检测是否有未保存的更改
+  // 仅在用户真正修改过(hasUnsavedChanges)时写入缓存,供异常退出后恢复使用
   useEffect(() => {
-    // 只有当有节点或边时才保存缓存(避免初始化时保存空数据)
-    if (workflowId && (nodes.length > 0 || edges.length > 0)) {
+    if (workflowId && (nodes.length > 0 || edges.length > 0) && hasUnsavedChanges) {
       saveWorkflowCache(workflowId, nodes, edges);
     }
-    
-    // 检测是否有未保存的更改(只有在初始状态已设置后才检测)
-    // 如果初始状态和当前状态都为空,则没有未保存的更改
-    if ((initialNodesRef.current.length > 0 || initialEdgesRef.current.length > 0) || 
-        (nodes.length > 0 || edges.length > 0)) {
-      const hasChanges = checkUnsavedChanges();
-      setHasUnsavedChanges(hasChanges);
-    } else {
-      // 如果初始状态和当前状态都为空,则没有未保存的更改
-      setHasUnsavedChanges(false);
-    }
-  }, [nodes, edges, workflowId, saveWorkflowCache, checkUnsavedChanges]);
+  }, [nodes, edges, workflowId, hasUnsavedChanges, saveWorkflowCache]);
 
   // 获取隔离方式字典数据
   const getIsolationMethodDictList = async () => {
@@ -1381,6 +1372,9 @@ export default function ProcessDesigner() {
 
   // 加载 JSON 数据到画布(可复用的函数)
   const loadJsonToCanvas = useCallback((jsonString: string, updateHistory: boolean = false) => {
+    if (updateHistory) {
+      isLoadingFromServerOrRestoreRef.current = true;
+    }
     try {
       const data = JSON.parse(jsonString);
       
@@ -1505,10 +1499,17 @@ export default function ProcessDesigner() {
         initialNodesRef.current = JSON.parse(JSON.stringify(importedNodes));
         initialEdgesRef.current = JSON.parse(JSON.stringify(importedEdges));
         setHasUnsavedChanges(false);
+        // 短时内不把后续的 onNodesChange/onEdgesChange 视为用户修改,避免保存后再次进入仍弹「未保存」框
+        setTimeout(() => {
+          isLoadingFromServerOrRestoreRef.current = false;
+        }, 200);
       }
 
       return true;
     } catch (error: any) {
+      if (updateHistory) {
+        isLoadingFromServerOrRestoreRef.current = false;
+      }
       console.error('加载 JSON 失败:', error);
       message.error('JSON 格式错误:' + (error?.message || '解析失败'));
       return false;
@@ -1544,15 +1545,17 @@ export default function ProcessDesigner() {
             // 内容不同,说明用户有修改,弹出确认框
             Modal.confirm({
               title: '检测到未保存的设计',
-              content: '检测到您有未保存的设计稿,需要恢复继续设计吗?',
+              content: '检测到您有未保存的设计稿(可能为异常退出),需要恢复继续设计吗?',
               okText: '恢复',
-              cancelText: '不',
+              cancelText: '不恢复',
               onOk: () => {
-                // 使用缓存内容
+                // 使用缓存内容恢复;恢复后视为「未保存」以便点击返回时提示保存或放弃
                 loadJsonToCanvas(cachedContent, true);
+                setHasUnsavedChanges(true);
               },
               onCancel: () => {
-                // 使用接口内容
+                // 使用接口内容,并清除缓存避免下次再提示
+                clearWorkflowCache(workflowId);
                 loadJsonToCanvas(detail.content, true);
               },
             });
@@ -1573,7 +1576,7 @@ export default function ProcessDesigner() {
     };
 
     fetchWorkflowDetail();
-  }, [workflowId, loadJsonToCanvas, loadWorkflowCache, compareJsonContent]);
+  }, [workflowId, loadJsonToCanvas, loadWorkflowCache, compareJsonContent, clearWorkflowCache]);
 
   // 拖拽处理
   const onDragStart = (event: React.DragEvent, nodeType: string) => {
@@ -1701,6 +1704,7 @@ export default function ProcessDesigner() {
         setHistoryIndex(newHistory.length - 1);
         return newEdges;
       });
+      setHasUnsavedChanges(true);
     },
     [setEdges, history, historyIndex, nodes]
   );
@@ -1766,6 +1770,7 @@ export default function ProcessDesigner() {
         
         return updatedEdges;
       });
+      setHasUnsavedChanges(true);
     },
     [history, historyIndex, nodes]
   );
@@ -1897,6 +1902,7 @@ export default function ProcessDesigner() {
       return newEdges;
     });
     setSelectedEdge(null);
+    setHasUnsavedChanges(true);
   }, [history, historyIndex, nodes]);
 
   // 更新全局删除函数和 edges
@@ -1983,6 +1989,7 @@ export default function ProcessDesigner() {
         if (selectedNode?.id === targetNodeId) {
           setSelectedNode(null);
         }
+        setHasUnsavedChanges(true);
         toast.success('节点已删除');
       },
     });
@@ -2033,6 +2040,7 @@ export default function ProcessDesigner() {
       setNodes(prevState.nodes);
       setEdges(prevState.edges);
       setHistoryIndex(historyIndex - 1);
+      setHasUnsavedChanges(true);
     }
   }, [history, historyIndex, setNodes, setEdges]);
 
@@ -2043,10 +2051,16 @@ export default function ProcessDesigner() {
       setNodes(nextState.nodes);
       setEdges(nextState.edges);
       setHistoryIndex(historyIndex + 1);
+      setHasUnsavedChanges(true);
     }
   }, [history, historyIndex, setNodes, setEdges]);
 
-  // 拖放处理
+  // 网格步长,与画布 snapGrid 一致
+  const GRID_SIZE = 16;
+  // 水平线磁吸阈值:放置时与已有节点 Y 相差在此范围内则对齐到该节点水平线
+  const HORIZONTAL_SNAP_THRESHOLD = 40;
+
+  // 拖放处理:支持网格对齐 + 与前一个节点同一水平线磁吸
   const onDrop = useCallback(
     (event: React.DragEvent) => {
       event.preventDefault();
@@ -2054,7 +2068,7 @@ export default function ProcessDesigner() {
       const type = event.dataTransfer.getData('application/reactflow');
       if (!type || !reactFlowInstance) return;
 
-      const position = reactFlowInstance.screenToFlowPosition({
+      let position = reactFlowInstance.screenToFlowPosition({
         x: event.clientX,
         y: event.clientY,
       });
@@ -2062,23 +2076,38 @@ export default function ProcessDesigner() {
       const config = nodeConfigs.find(c => c.type === type);
       const timestamp = Date.now();
       const nodeId = `${type}-${timestamp}`;
-      const nodeNumber = nodes.length + 1;
-      const newNode: Node = {
-        id: nodeId,
-        type,
-        position,
-        data: {
-          label: config?.label || type,
-          type,
-          nodeId: String(nodeNumber).padStart(3, '0'),
-          smsTemplateCode: 'false',
-          messageTemplateCode: 'false',
-          emailTemplateCode: 'false',
-          appTemplateCode: 'false',
-        },
-      };
 
       setNodes((nds) => {
+        // 网格对齐
+        position = {
+          x: Math.round(position.x / GRID_SIZE) * GRID_SIZE,
+          y: Math.round(position.y / GRID_SIZE) * GRID_SIZE,
+        };
+        // 水平线磁吸:若有已有节点,且当前 Y 与最后一个节点 Y 接近,则对齐到同一水平线
+        if (nds.length > 0) {
+          const lastNode = nds[nds.length - 1];
+          const refY = lastNode.position.y;
+          if (Math.abs(position.y - refY) <= HORIZONTAL_SNAP_THRESHOLD) {
+            position.y = refY;
+          }
+        }
+
+        const nodeNumber = nds.length + 1;
+        const newNode: Node = {
+          id: nodeId,
+          type,
+          position: { ...position },
+          data: {
+            label: config?.label || type,
+            type,
+            nodeId: String(nodeNumber).padStart(3, '0'),
+            smsTemplateCode: 'false',
+            messageTemplateCode: 'false',
+            emailTemplateCode: 'false',
+            appTemplateCode: 'false',
+          },
+        };
+
         const newNodes = nds.concat(newNode);
         // 保存历史
         const newHistory = history.slice(0, historyIndex + 1);
@@ -2087,8 +2116,9 @@ export default function ProcessDesigner() {
         setHistoryIndex(newHistory.length - 1);
         return newNodes;
       });
+      setHasUnsavedChanges(true);
     },
-    [reactFlowInstance, setNodes, history, historyIndex, edges, nodes.length]
+    [reactFlowInstance, setNodes, history, historyIndex, edges]
   );
 
   const onDragOver = useCallback((event: React.DragEvent) => {
@@ -2096,6 +2126,42 @@ export default function ProcessDesigner() {
     event.dataTransfer.dropEffect = 'move';
   }, []);
 
+  // 拖拽节点时的水平线磁吸:与画布上其他节点 Y 接近时对齐到同一水平线;节点变更时标记为已修改
+  const onNodesChange = useCallback(
+    (changes: NodeChange[]) => {
+      const modified = changes.map((ch) => {
+        if (ch.type === 'position' && ch.position != null && ch.dragging) {
+          const otherNodes = nodes.filter((n) => n.id !== ch.id);
+          let newY = ch.position!.y;
+          for (const n of otherNodes) {
+            if (Math.abs(ch.position!.y - n.position.y) <= HORIZONTAL_SNAP_THRESHOLD) {
+              newY = n.position.y;
+              break;
+            }
+          }
+          return { ...ch, position: { ...ch.position!, y: newY } };
+        }
+        return ch;
+      });
+      if (!isLoadingFromServerOrRestoreRef.current) {
+        setHasUnsavedChanges(true);
+      }
+      onNodesChangeBase(modified);
+    },
+    [nodes, onNodesChangeBase]
+  );
+
+  // 边变更时标记为已修改(仅用户操作会触发 onEdgesChange);加载/恢复阶段不标记
+  const onEdgesChangeWithDirty = useCallback(
+    (changes: Parameters<typeof onEdgesChange>[0]) => {
+      if (!isLoadingFromServerOrRestoreRef.current) {
+        setHasUnsavedChanges(true);
+      }
+      onEdgesChange(changes);
+    },
+    [onEdgesChange]
+  );
+
   // 更新节点配置
   const updateNodeConfig = useCallback(() => {
     if (!selectedNode) return;
@@ -2144,6 +2210,7 @@ export default function ProcessDesigner() {
       setHistoryIndex(newHistory.length - 1);
       return updatedNodes;
     });
+    setHasUnsavedChanges(true);
     toast.success('节点配置已保存');
   }, [selectedNode, nodeConfig, setNodes, setSelectedNode, history, historyIndex, edges]);
 
@@ -2388,14 +2455,14 @@ export default function ProcessDesigner() {
     }
   };
 
-  // 返回
+  // 返回(仅在有未保存修改时弹框:保存 或 放弃并返回)
   const handleBack = () => {
-    // 检查是否有未保存的更改
     if (hasUnsavedChanges) {
-      Modal.warning({
-        title: '提示',
-        content: '检测到您有未保存的更改,请先保存后再返回。',
+      Modal.confirm({
+        title: '未保存的更改',
+        content: '检测到您有未保存的更改,请先保存后再返回,或放弃更改并返回。',
         okText: '保存',
+        cancelText: '放弃并返回',
         maskClosable: false,
         closable: false,
         onOk: async () => {
@@ -2509,6 +2576,11 @@ export default function ProcessDesigner() {
             message.error(error?.message || '流程保存失败');
           }
         },
+        onCancel: () => {
+          // 放弃并返回:清除缓存后跳转到流程模板列表
+          clearWorkflowCache(workflowId);
+          backToProcessTemplateMenu();
+        },
       });
     } else {
       // 没有未保存的更改,直接返回
@@ -2980,7 +3052,7 @@ export default function ProcessDesigner() {
             nodes={nodes}
             edges={edges}
             onNodesChange={onNodesChange}
-            onEdgesChange={onEdgesChange}
+            onEdgesChange={onEdgesChangeWithDirty}
             onConnect={onConnect}
             onEdgeUpdate={onEdgeUpdate}
             isValidConnection={isValidConnection}
@@ -2997,6 +3069,9 @@ export default function ProcessDesigner() {
             onInit={setReactFlowInstance}
             onMove={(_, viewport) => setZoom(viewport.zoom)}
             connectionMode={ConnectionMode.Loose}
+            connectionRadius={40}
+            snapToGrid={true}
+            snapGrid={[16, 16]}
             defaultEdgeOptions={{
               style: { strokeWidth: 2, stroke: '#000000' },
               markerStart: {

+ 145 - 6
src/components/WorkJobDetail.tsx

@@ -14,7 +14,8 @@ import {
   List,
   Plus,
   Minus,
-  Loader2
+  Loader2,
+  Archive
 } from 'lucide-react';
 import { Timeline, Badge, Card, Collapse, Tag, Descriptions } from 'antd';
 import { CheckCircleOutlined, ClockCircleOutlined, SyncOutlined } from '@ant-design/icons';
@@ -58,6 +59,8 @@ interface WorkflowStep {
 interface FlowRecord {
   id: string;
   taskNode: string; // 任务节点
+  nodeType?: string; // 节点类型(用于归档列表边框区分)
+  nodeIcon?: string; // 节点图标文件名(与作业流程一致,用于归档列表展示)
   executor: string; // 任务负责人
   startTime?: string; // 开始时间
   endTime?: string; // 结束时间
@@ -1695,6 +1698,9 @@ export default function WorkJobDetail() {
   const [reactFlowInstance, setReactFlowInstance] = useState<any>(null);
   const [selectedNode, setSelectedNode] = useState<Node | null>(null);
 
+  // 是否展开归档信息面板(展开时左侧作业流程/作业信息/当前任务压缩,右侧显示归档信息列表)
+  const [showArchivePanel, setShowArchivePanel] = useState(false);
+
   // 节点信息相关状态
   const [formList, setFormList] = useState<any[]>([]);
   const [drawerUsers, setDrawerUsers] = useState<any[]>([]);
@@ -2669,6 +2675,8 @@ export default function WorkJobDetail() {
       return {
         id: String(node.id || index),
         taskNode: node.nodeName || '未知节点',
+        nodeType: node.type || 'default',
+        nodeIcon: node.nodeIcon || undefined,
         executor: executor,
         startTime: startTime,
         endTime: undefined,
@@ -2701,6 +2709,42 @@ export default function WorkJobDetail() {
     return statusMap[status] || 'bg-gray-100 text-gray-700';
   };
 
+  // 根据节点类型返回归档记录卡片样式(边框 + 类型标签,图标与作业流程一致由下方单独渲染)
+  // 类型标签统一使用背景色+文字色,避免有的只有文字变色
+  const getArchiveRecordStyle = (nodeType?: string): { borderClass: string; typeLabel: string; iconBg: string; typeLabelStyle: React.CSSProperties } => {
+    const type = (nodeType || 'default').toLowerCase();
+    const base = 'rounded-xl border-l-4 p-4 mb-3 bg-white shadow-sm hover:shadow-md transition-shadow';
+    const configs: Record<string, { border: string; label: string; iconBg: string; bg: string; text: string }> = {
+      createjob: { border: 'border-l-blue-500', label: '创建作业', iconBg: 'bg-blue-100 text-blue-600', bg: '#dbeafe', text: '#2563eb' },
+      review: { border: 'border-l-orange-500', label: '审核', iconBg: 'bg-orange-100 text-orange-600', bg: '#ffedd5', text: '#ea580c' },
+      confirm: { border: 'border-l-purple-500', label: '确认', iconBg: 'bg-purple-100 text-purple-600', bg: '#f3e8ff', text: '#9333ea' },
+      inputinfo: { border: 'border-l-green-500', label: '录入', iconBg: 'bg-green-100 text-green-600', bg: '#dcfce7', text: '#16a34a' },
+      isolation: { border: 'border-l-red-500', label: '能量隔离', iconBg: 'bg-red-100 text-red-600', bg: '#fee2e2', text: '#dc2626' },
+      releaseisolation: { border: 'border-l-amber-500', label: '解除隔离', iconBg: 'bg-amber-100 text-amber-600', bg: '#fef3c7', text: '#d97706' },
+      returnlock: { border: 'border-l-slate-500', label: '还锁', iconBg: 'bg-slate-100 text-slate-600', bg: '#f1f5f9', text: '#475569' },
+      complete: { border: 'border-l-gray-600', label: '结束', iconBg: 'bg-gray-100 text-gray-600', bg: '#f3f4f6', text: '#4b5563' },
+    };
+    const c = configs[type] || { border: 'border-l-gray-400', label: '节点', iconBg: 'bg-gray-100 text-gray-600', bg: '#f3f4f6', text: '#6b7280' };
+    const typeLabelStyle: React.CSSProperties = { backgroundColor: c.bg, color: c.text };
+    return { borderClass: `${base} ${c.border}`, typeLabel: c.label, iconBg: c.iconBg, typeLabelStyle };
+  };
+
+  // 归档列表:渲染与作业流程一致的节点图标(优先自定义 nodeIcon 图片,否则用 getNodeIcon 按类型+状态)
+  const renderArchiveNodeIcon = (record: FlowRecord): React.ReactNode => {
+    const iconPath = getIconPathByFileName(record.nodeIcon);
+    if (iconPath) {
+      return (
+        <img
+          src={iconPath}
+          alt={record.taskNode || '节点图标'}
+          className="w-6 h-6 object-contain"
+          onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }}
+        />
+      );
+    }
+    return getNodeIcon(record.nodeType || 'default', record.taskStatus);
+  };
+
   const flowRecords = buildFlowRecords();
 
   if (loading) {
@@ -2772,10 +2816,18 @@ export default function WorkJobDetail() {
           </div>
         </div>
 
-        {/* 任务详情和执行记录区域 - 左右两栏布局 */}
+        {/* 任务详情和执行记录区域 - 左右两栏布局(展开归档时左侧压缩、右侧显示归档信息列表) */}
         <div className="flex-1 flex gap-8 mb-6 min-h-0">
-          {/* 左侧:任务详情 */}
-          <div className="bg-white rounded-xl shadow-sm border border-gray-200 flex flex-col min-h-0" style={{ marginLeft: '20px', maxHeight: '750px', width: '1400px', flexShrink: 0 }}>
+          {/* 左侧:作业流程 */}
+          <div 
+            className="bg-white rounded-xl shadow-sm border border-gray-200 flex flex-col min-h-0 transition-all" 
+            style={{ 
+              marginLeft: '20px', 
+              maxHeight: '790px', 
+              width: showArchivePanel ? '900px' : '1400px', 
+              flexShrink: 0 
+            }}
+          >
             <div className="px-6 py-4 border-b border-gray-200">
               <h2 className="text-lg font-semibold text-blue-600 flex items-center gap-2">
                 <FileText className="w-5 h-5" />
@@ -2839,8 +2891,16 @@ export default function WorkJobDetail() {
             </div>
           </div>
 
-          {/* 右侧:执行进度 */}
-          <div className="flex-1 flex flex-col gap-4 min-h-0 flex-shrink-0" style={{ marginRight: '20px', maxHeight: '750px' }}>
+          {/* 右侧:作业信息 + 当前任务(展开归档时与归档信息列表并排) */}
+          <div 
+            className="flex-1 flex gap-4 min-h-0 flex-shrink-0 overflow-hidden" 
+            style={{ marginRight: '20px', maxHeight: '790px', flexDirection: showArchivePanel ? 'row' : 'column' }}
+          >
+            {/* 作业信息 + 当前任务 组合区域 */}
+            <div 
+              className="flex flex-col gap-4 min-h-0 overflow-hidden transition-all" 
+              style={{ flex: showArchivePanel ? '0 0 530px' : '1 1 auto', minWidth: showArchivePanel ? 530 : undefined }}
+            >
             {/* 上部分卡片:作业信息 */}
             <div className="bg-white rounded-xl shadow-sm border border-gray-200 flex-shrink-0">
               <div className="px-6 py-4 border-b border-gray-200 flex items-center justify-between">
@@ -2848,6 +2908,15 @@ export default function WorkJobDetail() {
                   <FileText className="w-5 h-5" />
                   作业信息
                 </h2>
+                <button
+                  type="button"
+                  onClick={() => setShowArchivePanel(!showArchivePanel)}
+                  className="flex items-center gap-2 px-3 py-1.5 rounded-lg border border-gray-300 hover:bg-gray-50 hover:border-blue-400 text-sm text-gray-700 transition-colors"
+                  title={showArchivePanel ? '收起归档信息' : '展开归档信息'}
+                >
+                  <Archive className="w-4 h-4" />
+                  {showArchivePanel ? '收起归档' : '归档信息'}
+                </button>
               </div>
               <div className="p-4">
                 <style>{`
@@ -2975,6 +3044,76 @@ export default function WorkJobDetail() {
                 )}
               </div>
             </div>
+            </div>
+
+            {/* 归档信息列表卡片(仅在展开归档时显示) */}
+            {showArchivePanel && (
+              <div className="bg-white rounded-xl shadow-sm border border-gray-200 flex-1 flex flex-col min-h-0 overflow-hidden min-w-0">
+                <div className="px-6 py-4 border-b border-gray-200 flex-shrink-0">
+                  <h2 className="text-lg font-semibold text-blue-600 flex items-center gap-2">
+                    <Archive className="w-5 h-5" />
+                    归档信息列表
+                  </h2>
+                  <p className="text-xs text-gray-500 mt-1">当前作业执行流水记录,按节点类型区分展示</p>
+                </div>
+                <div className="flex-1 overflow-y-auto p-4">
+                  {flowRecords.length === 0 ? (
+                    <div className="text-center text-gray-400 py-8 text-sm">暂无执行记录</div>
+                  ) : (
+                    <div className="space-y-1">
+                      {flowRecords.map((record) => {
+                        const style = getArchiveRecordStyle(record.nodeType);
+                        return (
+                          <div key={record.id} className={style.borderClass}>
+                            <div className="flex items-start gap-3">
+                              <div className="flex-shrink-0 w-10 h-10 rounded-lg flex items-center justify-center [&_svg]:w-5 [&_svg]:h-5 [&_svg]:text-current" style={style.typeLabelStyle}>
+                                {renderArchiveNodeIcon(record)}
+                              </div>
+                              <div className="flex-1 min-w-0">
+                                <div className="flex items-center justify-between gap-2 flex-wrap">
+                                  <div className="flex items-center gap-2 flex-wrap">
+                                    <span className="font-semibold text-gray-900">{record.taskNode}</span>
+                                    <span className="inline-flex items-center gap-1 px-2.5 py-1 rounded-md text-xs font-medium" style={style.typeLabelStyle}>
+                                      {style.typeLabel}
+                                    </span>
+                                  </div>
+                                  <span className={`inline-flex px-2.5 py-1 rounded-full text-xs font-medium ${getFlowRecordStatusClassName(record.taskStatus)}`}>
+                                    {getFlowRecordStatusText(record.taskStatus)}
+                                  </span>
+                                </div>
+                                {(record.executor || record.startTime) && (
+                                  <div className="mt-2.5 flex items-center gap-3 flex-wrap">
+                                    {record.executor && (
+                                      <div className="flex items-center gap-2">
+                                        <span className="flex-shrink-0 w-7 h-7 rounded-full bg-gray-100 text-gray-600 flex items-center justify-center">
+                                          <User className="w-4 h-4" />
+                                        </span>
+                                        <span className="text-sm text-gray-700">{record.executor}</span>
+                                      </div>
+                                    )}
+                                    {record.startTime && (
+                                      <span className="text-sm text-gray-500 flex items-center gap-1">
+                                        <Clock className="w-3.5 h-3.5" />
+                                        {record.startTime}
+                                      </span>
+                                    )}
+                                  </div>
+                                )}
+                                {record.executionDescription && (
+                                  <div className="mt-1.5 text-sm text-gray-500 truncate pl-0" title={record.executionDescription}>
+                                    {record.executionDescription}
+                                  </div>
+                                )}
+                              </div>
+                            </div>
+                          </div>
+                        );
+                      })}
+                    </div>
+                  )}
+                </div>
+              </div>
+            )}
           </div>
         </div>
       </div>

+ 29 - 0
src/components/lockCabinet/LockCabinet3D.css

@@ -611,6 +611,18 @@
   box-shadow: 0 0 6px #00ff00;
 }
 
+/* 钥匙仓:在线时显示的钥匙图片(30x30 固定) */
+.lc3dKeySlot .lc3dSlotHardwareImg {
+  position: absolute;
+  left: 50%;
+  top: 50%;
+  transform: translate(-50%, -50%);
+  width: 30px;
+  height: 30px;
+  object-fit: contain;
+  pointer-events: none;
+}
+
 /* ========== 锁仓区域 ========== */
 .lc3dLockSection {
   padding: 10px 0;
@@ -685,6 +697,11 @@
   background: radial-gradient(circle at 30% 30%, #555, #333);
 }
 
+.lc3dLockIndicatorGreen {
+  background: radial-gradient(circle at 30% 30%, #66ff66, #00aa00);
+  box-shadow: 0 0 6px #00ff00;
+}
+
 .lc3dLockHole {
   width: 20px;
   height: 25px;
@@ -694,6 +711,18 @@
   box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.5);
 }
 
+/* 锁仓:在线时显示的挂锁图片(宽 30、高 20),下移 5px 避免挡住状态灯 */
+.lc3dLockSlot .lc3dSlotHardwareLockImg {
+  position: absolute;
+  left: 50%;
+  top: calc(50% + 5px);
+  transform: translate(-50%, -50%);
+  width: 30px;
+  height: 20px;
+  object-fit: contain;
+  pointer-events: none;
+}
+
 /* 边框螺丝装饰 */
 .lc3dScrew {
   position: absolute;

+ 161 - 18
src/components/lockCabinet/LockCabinetDetail.tsx

@@ -1,14 +1,35 @@
-import React, { useState } from 'react';
+import React, { useState, useEffect } from 'react';
 import { useSearchParams, useNavigate } from 'react-router-dom';
-import { ArrowLeft, RefreshCw } from 'lucide-react';
-import { Button } from '../ui/button';
+import { Modal, Input, message, Button } from 'antd';
+import { CopyOutlined, DownloadOutlined, UploadOutlined, ReloadOutlined, LeftOutlined } from '@ant-design/icons';
 import MapData from './MapData';
+import { lockCabinetApi, LockCabinetVO } from '../../api/lockCabinet';
 
 export default function LockCabinetDetail() {
   const [searchParams] = useSearchParams();
   const navigate = useNavigate();
   const cabinetId = searchParams.get('cabinetId') || '';
   const [refreshKey, setRefreshKey] = useState(0);
+  const [cabinetInfo, setCabinetInfo] = useState<LockCabinetVO | null>(null);
+
+  // 钥匙/锁状态 JSON:存储在机柜 remark 字段,用于导出/导入弹框
+  const [exportVisible, setExportVisible] = useState(false);
+  const [exportContent, setExportContent] = useState('');
+  const [importVisible, setImportVisible] = useState(false);
+  const [importJson, setImportJson] = useState('');
+
+  useEffect(() => {
+    if (cabinetId) {
+      lockCabinetApi.selectIsLockCabinetById(Number(cabinetId))
+        .then(setCabinetInfo)
+        .catch((err: any) => {
+          console.error('获取机柜信息失败:', err);
+          message.error(err?.message || '获取机柜信息失败');
+        });
+    } else {
+      setCabinetInfo(null);
+    }
+  }, [cabinetId]);
 
   const handleBack = () => {
     // 读取来源菜单信息,如果存在则使用它,否则默认使用硬件管理 -> 机柜
@@ -39,6 +60,78 @@ export default function LockCabinetDetail() {
     setRefreshKey(prev => prev + 1);
   };
 
+  // 导出 JSON:将 remark 内容展示在弹框中(多行文本)
+  const handleExportJson = () => {
+    const raw = cabinetInfo?.remark ?? '';
+    try {
+      const parsed = raw.trim() ? JSON.parse(raw) : null;
+      setExportContent(parsed !== null ? JSON.stringify(parsed, null, 2) : '');
+    } catch {
+      setExportContent(raw);
+    }
+    setExportVisible(true);
+  };
+
+  // 复制 JSON 到剪贴板
+  const handleCopyJson = async () => {
+    if (!exportContent.trim()) {
+      message.warning('没有可复制的内容');
+      return;
+    }
+    try {
+      await navigator.clipboard.writeText(exportContent);
+      message.success('复制成功,JSON 已复制到剪贴板');
+    } catch (err: any) {
+      message.error(err?.message || '复制失败');
+    }
+  };
+
+  // 导入 JSON:将弹框中的内容写入 remark 并调用机柜编辑接口(后端需要 id 作为主键)
+  const handleImportJson = async () => {
+    const trimmed = importJson.trim();
+    try {
+      if (trimmed) {
+        JSON.parse(trimmed);
+      }
+    } catch {
+      message.error('JSON 格式错误,请检查后重试');
+      return;
+    }
+    const primaryId = (cabinetInfo as any)?.id ?? cabinetInfo?.cabinetId;
+    if (!primaryId) {
+      message.error('机柜信息不存在,无法保存');
+      return;
+    }
+    try {
+      const payload: LockCabinetVO & { id?: number } = {
+        ...cabinetInfo,
+        cabinetName: cabinetInfo!.cabinetName,
+        remark: trimmed,
+      };
+      (payload as any).id = primaryId;
+      delete (payload as any).cabinetId;
+      await lockCabinetApi.updateIsLockCabinet(payload as LockCabinetVO);
+      setCabinetInfo(prev => prev ? { ...prev, remark: trimmed } : null);
+      setRefreshKey(prev => prev + 1);
+      setImportVisible(false);
+      setImportJson('');
+      message.success('导入成功,钥匙/锁状态已更新');
+    } catch (err: any) {
+      message.error(err?.message || '保存失败');
+    }
+  };
+
+  const openImportModal = () => {
+    const raw = cabinetInfo?.remark ?? '';
+    try {
+      const parsed = raw.trim() ? JSON.parse(raw) : null;
+      setImportJson(parsed !== null ? JSON.stringify(parsed, null, 2) : '');
+    } catch {
+      setImportJson(raw);
+    }
+    setImportVisible(true);
+  };
+
   return (
     <div className="h-screen flex flex-col overflow-hidden bg-gradient-to-br from-gray-50 via-blue-50/30 to-gray-50">
       {/* 顶部导航栏 */}
@@ -50,23 +143,18 @@ export default function LockCabinetDetail() {
               <h1 className="text-lg text-gray-900 font-semibold">锁控柜可视化管理</h1>
             </div>
 
-            {/* 右侧:刷新和返回按钮 */}
-            <div className="flex items-center gap-3">
-              <Button
-                onClick={handleRefresh}
-                variant="ghost"
-                className="flex items-center gap-2 hover:opacity-90"
-                style={{ backgroundColor: '#2563eb', color: '#fff' }}
-              >
-                <RefreshCw className="w-4 h-4" />
+            {/* 右侧:导入JSON、导出JSON、刷新和返回按钮(antd 风格) */}
+            <div className="flex items-center gap-2">
+              <Button type="default" icon={<DownloadOutlined />} onClick={handleExportJson}>
+                导出JSON
+              </Button>
+              <Button type="default" icon={<UploadOutlined />} onClick={openImportModal}>
+                导入JSON
+              </Button>
+              <Button type="primary" icon={<ReloadOutlined />} onClick={handleRefresh}>
                 刷新
               </Button>
-              <Button
-                variant="ghost"
-                onClick={handleBack}
-                className="flex items-center gap-2 hover:bg-gray-100"
-              >
-                <ArrowLeft className="w-4 h-4" />
+              <Button type="default" icon={<LeftOutlined />} onClick={handleBack}>
                 返回
               </Button>
             </div>
@@ -80,6 +168,61 @@ export default function LockCabinetDetail() {
           <MapData key={refreshKey} cabinetId={cabinetId} />
         </div>
       </div>
+
+      {/* 导出 JSON 弹框:多行文本展示 remark 内容 */}
+      <Modal
+        open={exportVisible}
+        title="钥匙/锁状态 JSON 导出"
+        onCancel={() => setExportVisible(false)}
+        onOk={() => setExportVisible(false)}
+        width={800}
+        okText="关闭"
+        cancelButtonProps={{ style: { display: 'none' } }}
+        style={{ top: 20 }}
+        styles={{ body: { maxHeight: 'calc(90vh - 120px)', overflowY: 'auto', overflowX: 'hidden', padding: 0 } }}
+      >
+        <div className="relative">
+          <div className="sticky top-0 bg-white z-10 px-6 py-3 flex justify-end border-b border-gray-100">
+            <Button type="default" icon={<CopyOutlined />} onClick={handleCopyJson}>
+              复制JSON
+            </Button>
+          </div>
+          <div className="p-6">
+            <div className="bg-gray-50 border border-gray-200 rounded-lg p-3 overflow-x-hidden">
+              <pre className="text-xs text-gray-800 whitespace-pre-wrap break-words font-mono overflow-x-hidden min-h-[200px]">
+                {exportContent || '(暂无数据,请先导入 JSON)'}
+              </pre>
+            </div>
+          </div>
+        </div>
+      </Modal>
+
+      {/* 导入 JSON 弹框:多行文本编辑,保存到 remark 并调用更新接口 */}
+      <Modal
+        open={importVisible}
+        title="导入钥匙/锁状态 JSON"
+        onCancel={() => {
+          setImportVisible(false);
+          setImportJson('');
+        }}
+        onOk={handleImportJson}
+        width={800}
+        okText="导入"
+        cancelText="取消"
+      >
+        <div className="space-y-3">
+          <p className="text-sm text-gray-600">
+            请粘贴或编辑钥匙仓/锁仓状态 JSON 数据,导入后将更新机柜备注(remark)并刷新页面展示
+          </p>
+          <Input.TextArea
+            value={importJson}
+            onChange={(e) => setImportJson(e.target.value)}
+            placeholder="请粘贴或输入 JSON 数据..."
+            rows={15}
+            className="font-mono text-xs"
+          />
+        </div>
+      </Modal>
     </div>
   );
 }

+ 49 - 12
src/components/lockCabinet/MapData.tsx

@@ -4,6 +4,8 @@ import { Clock } from 'lucide-react';
 import { slotApi, LockCabinetSlotVO, SlotPageParam } from '../../api/lockCabinet/slots';
 import { lockCabinetApi, LockCabinetVO } from '../../api/lockCabinet';
 import { dateFormatter } from '../../utils/formatTime';
+import keyImg from '../../assets/key.png';
+import lockImg from '../../assets/lock.png';
 import './LockCabinet3D.css';
 
 interface MapDataProps {
@@ -245,6 +247,32 @@ export default function MapData({ cabinetId }: MapDataProps) {
     return rows;
   }, [slotData]);
 
+  // 从机柜详情 remark 解析钥匙/锁仓状态(keys/locks,slotNo,status: online/offline),用于 3D 仓位渲染
+  const remarkSlots = React.useMemo(() => {
+    const keyOnline: Record<number, boolean> = {};
+    const lockOnline: Record<number, boolean> = {};
+    try {
+      const raw = cabinetInfo?.remark?.trim() || '';
+      if (!raw) return { keyOnline, lockOnline };
+      const data = JSON.parse(raw);
+      const keys = data.keys || data.钥匙仓 || [];
+      const locks = data.locks || data.锁仓 || [];
+      keys.forEach((item: any) => {
+        const no = item.slotNo ?? item.编号 ?? 0;
+        const status = (item.status ?? item.状态 ?? '').toLowerCase();
+        keyOnline[no] = status === 'online' || status === '在线';
+      });
+      locks.forEach((item: any) => {
+        const no = item.slotNo ?? item.编号 ?? 0;
+        const status = (item.status ?? item.状态 ?? '').toLowerCase();
+        lockOnline[no] = status === 'online' || status === '在线';
+      });
+    } catch {
+      /* ignore parse error */
+    }
+    return { keyOnline, lockOnline };
+  }, [cabinetInfo?.remark]);
+
   return (
     <div className="flex gap-6 h-full min-h-0">
       {/* 左侧:3D 机柜 */}
@@ -382,23 +410,25 @@ export default function MapData({ cabinetId }: MapDataProps) {
                         <div className="lc3dDrawerHandle" />
                       </div>
 
-                      {/* 钥匙仓区域 */}
+                      {/* 钥匙仓区域:根据 remark 在线/离线渲染状态灯,红灯=有东西,绿灯=没有东西 */}
                       <div className="lc3dKeySection">
                         <div className="lc3dKeySectionTitle">钥匙仓</div>
                         <div className="lc3dKeyRow">
                           {Array.from({ length: 4 }).map((_, idx) => {
+                            const slotNo = idx + 1;
                             const slot = keySlots[idx];
-                            const isAbnormal = slot?.status === '1';
-                            const isOccupied = slot?.isOccupied === '1';
-                            const indicatorClass = isAbnormal
-                              ? 'lc3dKeyIndicatorRed'
-                              : isOccupied
-                                ? 'lc3dKeyIndicatorRed'
-                                : 'lc3dKeyIndicatorGreen';
+                            const isOnlineFromRemark = remarkSlots.keyOnline[slotNo];
+                            const useRemark = cabinetInfo?.remark?.trim();
+                            const indicatorClass = useRemark
+                              ? (isOnlineFromRemark ? 'lc3dKeyIndicatorRed' : 'lc3dKeyIndicatorGreen')
+                              : (slot?.status === '1' || slot?.isOccupied === '1' ? 'lc3dKeyIndicatorRed' : 'lc3dKeyIndicatorGreen');
 
                             return (
                               <div key={idx} className="lc3dKeySlot">
-                                <span className="lc3dKeyNumber">{idx + 1}</span>
+                                <span className="lc3dKeyNumber">{slotNo}</span>
+                                {isOnlineFromRemark && (
+                                  <img src={keyImg} alt="钥匙" className="lc3dSlotHardwareImg" style={{ width: 30, height: 30 }} title="在线" />
+                                )}
                                 <div className={`lc3dKeyIndicator ${indicatorClass}`} />
                               </div>
                             );
@@ -406,7 +436,7 @@ export default function MapData({ cabinetId }: MapDataProps) {
                         </div>
                       </div>
 
-                      {/* 锁仓区域 */}
+                      {/* 锁仓区域:根据 remark 在线/离线渲染状态灯,红灯=有东西,绿灯=没有东西 */}
                       <div className="lc3dLockSection">
                         <div className="lc3dLockSectionTitle">锁仓</div>
                         {lockSlots.map((row, rowIdx) => (
@@ -414,11 +444,18 @@ export default function MapData({ cabinetId }: MapDataProps) {
                             {Array.from({ length: 10 }).map((_, slotIdx) => {
                               const slot = row[slotIdx];
                               const num = rowIdx * 10 + slotIdx + 1;
-                              const isOn = slot?.status === '1' || slot?.isOccupied === '1';
+                              const isOnlineFromRemark = remarkSlots.lockOnline[num];
+                              const useRemark = cabinetInfo?.remark?.trim();
+                              const lockIndicatorClass = useRemark
+                                ? (isOnlineFromRemark ? 'lc3dLockIndicatorRed' : 'lc3dLockIndicatorGreen')
+                                : (slot?.status === '1' || slot?.isOccupied === '1' ? 'lc3dLockIndicatorRed' : 'lc3dLockIndicatorOff');
                               return (
                                 <div key={slotIdx} className="lc3dLockSlot">
                                   <span className="lc3dLockNumber">{num}</span>
-                                  <div className={`lc3dLockIndicator ${isOn ? 'lc3dLockIndicatorRed' : 'lc3dLockIndicatorOff'}`} />
+                                  {isOnlineFromRemark && (
+                                    <img src={lockImg} alt="锁" className="lc3dSlotHardwareLockImg" style={{ width: 30, height: 20 }} title="在线" />
+                                  )}
+                                  <div className={`lc3dLockIndicator ${lockIndicatorClass}`} />
                                   <div className="lc3dLockHole" />
                                 </div>
                               );