|
|
@@ -0,0 +1,917 @@
|
|
|
+import React, { useState, useEffect, useRef } from 'react';
|
|
|
+import { useSearchParams, useNavigate } from 'react-router-dom';
|
|
|
+import { toast } from 'sonner';
|
|
|
+import { ArrowLeft, Check, CheckCircle2, Clock, FileDown, FileText, Flag, Lock, Loader2, Printer, Settings, Shield } from 'lucide-react';
|
|
|
+import ReactFlow, { Node, Edge, useNodesState, useEdgesState, Background, BackgroundVariant, Handle, Position, ReactFlowProvider } from 'reactflow';
|
|
|
+import type { NodeTypes } from 'reactflow';
|
|
|
+import 'reactflow/dist/style.css';
|
|
|
+import { workJobApi } from '../api/WorkJob';
|
|
|
+import { workHandleApi } from '../api/WorkHandle';
|
|
|
+import { workflowDesignApi } from '../api/WorkflowDesign';
|
|
|
+import { formatDateWithFormat } from '../utils/formatTime';
|
|
|
+import { getUser, getTenantName } from '../utils/auth';
|
|
|
+import { Avatar, AvatarFallback, AvatarImage } from './ui/avatar';
|
|
|
+import './WorkJobArchiveReport.css';
|
|
|
+
|
|
|
+/** 生成水印背景样式:显示 用户名@租户名称 */
|
|
|
+function useWatermarkStyle(): React.CSSProperties {
|
|
|
+ return React.useMemo(() => {
|
|
|
+ const user = getUser();
|
|
|
+ const tenantName = getTenantName();
|
|
|
+ const username = user?.nickname ?? user?.name ?? user?.username ?? '';
|
|
|
+ const tenant = tenantName ?? user?.tenantName ?? '';
|
|
|
+ const text = [username, tenant].filter(Boolean).length > 0
|
|
|
+ ? `${username}@${tenant}`
|
|
|
+ : '内部归档 INTERNAL ONLY';
|
|
|
+ const svg = `<svg width="400" height="400" xmlns="http://www.w3.org/2000/svg"><text x="50%" y="50%" font-size="20" fill="#f3f4f6" transform="rotate(-45 200 200)" text-anchor="middle">${text}</text></svg>`;
|
|
|
+ const dataUrl = `url("data:image/svg+xml,${encodeURIComponent(svg)}")`;
|
|
|
+ return { backgroundImage: dataUrl };
|
|
|
+ }, []);
|
|
|
+}
|
|
|
+
|
|
|
+const iconModulesForNode = (import.meta as any).glob('../assets/节点图标/**/*.png', { eager: true, as: 'url' });
|
|
|
+const getIconPathByFileName = (fileName: string | undefined): string | null => {
|
|
|
+ if (!fileName) return null;
|
|
|
+ let actualFileName = fileName;
|
|
|
+ if (fileName.includes('/') || fileName.includes('\\')) {
|
|
|
+ const pathParts = fileName.replace(/\\/g, '/').split('/');
|
|
|
+ actualFileName = pathParts[pathParts.length - 1];
|
|
|
+ }
|
|
|
+ const match = actualFileName.match(/^(\d+)\.png$/);
|
|
|
+ if (!match) return null;
|
|
|
+ const iconNumber = parseInt(match[1], 10);
|
|
|
+ let category = '';
|
|
|
+ if (iconNumber >= 1000 && iconNumber <= 1011) category = '审核';
|
|
|
+ else if (iconNumber >= 2000 && iconNumber <= 2016) category = '开始';
|
|
|
+ else if (iconNumber >= 3000 && iconNumber <= 3028) category = '录入';
|
|
|
+ else if (iconNumber >= 4000 && iconNumber <= 4024) category = '确认';
|
|
|
+ else if (iconNumber >= 5000 && iconNumber <= 5018) category = '结束';
|
|
|
+ else if (iconNumber >= 6000 && iconNumber <= 6027) category = '能量隔离';
|
|
|
+ else if (iconNumber >= 7000 && iconNumber <= 7021) category = '解除隔离';
|
|
|
+ if (!category) return null;
|
|
|
+ const allKeys = Object.keys(iconModulesForNode);
|
|
|
+ const matchingKey = allKeys.find(k => {
|
|
|
+ const normalizedKey = k.replace(/\\/g, '/').toLowerCase();
|
|
|
+ return normalizedKey.includes(category.toLowerCase()) && normalizedKey.endsWith(actualFileName.toLowerCase());
|
|
|
+ });
|
|
|
+ return matchingKey ? (iconModulesForNode[matchingKey] as string) : null;
|
|
|
+};
|
|
|
+
|
|
|
+const getNodeIcon = (type: string, status: 'completed' | 'in_progress' | 'pending') => {
|
|
|
+ const iconClass = status === 'completed' ? 'text-green-600' : status === 'in_progress' ? 'text-blue-600' : 'text-gray-400';
|
|
|
+ switch (type) {
|
|
|
+ case 'createJob': return <CheckCircle2 className={`w-5 h-5 ${iconClass}`} />;
|
|
|
+ case 'review':
|
|
|
+ case 'confirm': return <Settings className={`w-5 h-5 ${iconClass}`} />;
|
|
|
+ case 'inputInfo': return <FileText className={`w-5 h-5 ${iconClass}`} />;
|
|
|
+ case 'isolation': return <Shield className={`w-5 h-5 ${iconClass}`} />;
|
|
|
+ case 'releaseIsolation':
|
|
|
+ case 'returnLock': return <Lock className={`w-5 h-5 ${iconClass}`} />;
|
|
|
+ case 'complete': return <Flag className={`w-5 h-5 ${iconClass}`} />;
|
|
|
+ default: return <FileText className={`w-5 h-5 ${iconClass}`} />;
|
|
|
+ }
|
|
|
+};
|
|
|
+
|
|
|
+/** 与作业详情页一致的节点组件(SimpleCustomNode) */
|
|
|
+function SimpleCustomNode({ data, selected, id }: any) {
|
|
|
+ const nodeId = data.nodeId || (id ? String(parseInt(id.split('-').pop() || '0') % 1000).padStart(3, '0') : '001');
|
|
|
+ const status: 'completed' | 'in_progress' | 'pending' = data.status || 'pending';
|
|
|
+ let borderClass = 'border-gray-400';
|
|
|
+ let borderStyle: React.CSSProperties = { borderWidth: '3px' };
|
|
|
+ let shadowStyle: React.CSSProperties = {};
|
|
|
+ if (selected) {
|
|
|
+ borderClass = 'border-blue-500 shadow-md ring-2 ring-blue-300';
|
|
|
+ borderStyle = { borderWidth: '4px' };
|
|
|
+ shadowStyle = { boxShadow: '0 0 0 3px rgba(59, 130, 246, 0.1)' };
|
|
|
+ } else {
|
|
|
+ if (status === 'completed') {
|
|
|
+ borderClass = 'border-green-500';
|
|
|
+ shadowStyle = { boxShadow: '0 0 0 1px rgba(34, 197, 94, 0.1)' };
|
|
|
+ } else if (status === 'in_progress') {
|
|
|
+ borderClass = 'border-blue-500';
|
|
|
+ shadowStyle = { boxShadow: '0 0 0 1px rgba(59, 130, 246, 0.1)' };
|
|
|
+ } else {
|
|
|
+ borderClass = 'border-gray-400';
|
|
|
+ }
|
|
|
+ }
|
|
|
+ const getStatusText = (s: 'completed' | 'in_progress' | 'pending') =>
|
|
|
+ s === 'completed' ? '已完成' : s === 'in_progress' ? '进行中' : '待执行';
|
|
|
+ const getStatusTextColor = (s: 'completed' | 'in_progress' | 'pending') =>
|
|
|
+ s === 'completed' ? 'text-green-600' : s === 'in_progress' ? 'text-blue-600' : 'text-gray-500';
|
|
|
+ let iconImagePath: string | null = null;
|
|
|
+ if (data.icon && /^\d+\.png$/.test(data.icon)) iconImagePath = getIconPathByFileName(data.icon);
|
|
|
+ return (
|
|
|
+ <div className={`relative px-4 py-4 rounded-lg shadow-sm w-[180px] h-auto min-h-[140px] bg-white ${borderClass} transition-all`} style={{ ...borderStyle, ...shadowStyle }}>
|
|
|
+ <Handle id="top-source" type="source" position={Position.Top} className="!w-3 !h-3 !bg-green-500 !border-2 !border-white !rounded-full !shadow-sm" style={{ top: -6, left: '50%', transform: 'translateX(-50%)' }} isConnectable={false} />
|
|
|
+ <Handle id="top-target" type="target" position={Position.Top} className="!w-3 !h-3 !bg-green-500 !border-2 !border-white !rounded-full !shadow-sm" style={{ top: -6, left: '50%', transform: 'translateX(-50%)' }} isConnectable={false} />
|
|
|
+ <Handle id="bottom-source" type="source" position={Position.Bottom} className="!w-3 !h-3 !bg-green-500 !border-2 !border-white !rounded-full !shadow-sm" style={{ bottom: -6, left: '50%', transform: 'translateX(-50%)' }} isConnectable={false} />
|
|
|
+ <Handle id="bottom-target" type="target" position={Position.Bottom} className="!w-3 !h-3 !bg-green-500 !border-2 !border-white !rounded-full !shadow-sm" style={{ bottom: -6, left: '50%', transform: 'translateX(-50%)' }} isConnectable={false} />
|
|
|
+ <Handle id="left-source" type="source" position={Position.Left} className="!w-3 !h-3 !bg-green-500 !border-2 !border-white !rounded-full !shadow-sm" style={{ left: -6, top: '50%', transform: 'translateY(-50%)' }} isConnectable={false} />
|
|
|
+ <Handle id="left-target" type="target" position={Position.Left} className="!w-3 !h-3 !bg-green-500 !border-2 !border-white !rounded-full !shadow-sm" style={{ left: -6, top: '50%', transform: 'translateY(-50%)' }} isConnectable={false} />
|
|
|
+ <Handle id="right-source" type="source" position={Position.Right} className="!w-3 !h-3 !bg-green-500 !border-2 !border-white !rounded-full !shadow-sm" style={{ right: -6, top: '50%', transform: 'translateY(-50%)' }} isConnectable={false} />
|
|
|
+ <Handle id="right-target" type="target" position={Position.Right} className="!w-3 !h-3 !bg-green-500 !border-2 !border-white !rounded-full !shadow-sm" style={{ right: -6, top: '50%', transform: 'translateY(-50%)' }} isConnectable={false} />
|
|
|
+ <div className="flex flex-col items-center justify-between gap-3 h-full">
|
|
|
+ <div className="bg-gray-50 border border-gray-100 p-3 rounded-xl shadow-sm flex items-center justify-center flex-shrink-0" style={{ borderRadius: '12px' }}>
|
|
|
+ {iconImagePath ? (
|
|
|
+ <img src={iconImagePath} alt={data.icon || '节点图标'} className="w-6 h-6 object-contain" onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }} />
|
|
|
+ ) : (
|
|
|
+ getNodeIcon(data.type || 'createJob', status)
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+ <div className="font-semibold text-sm text-gray-900 leading-tight text-center break-words w-full flex-1 flex items-center justify-center px-1">
|
|
|
+ {data.label || data.nodeName || '节点'}
|
|
|
+ </div>
|
|
|
+ <div className="text-xs text-gray-500 text-center flex-shrink-0">ID: {nodeId}</div>
|
|
|
+ <div className={`text-xs font-medium text-center flex-shrink-0 ${getStatusTextColor(status)}`}>{getStatusText(status)}</div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+}
|
|
|
+
|
|
|
+const archiveNodeTypes: NodeTypes = {
|
|
|
+ createJob: SimpleCustomNode,
|
|
|
+ confirm: SimpleCustomNode,
|
|
|
+ review: SimpleCustomNode,
|
|
|
+ inputInfo: SimpleCustomNode,
|
|
|
+ isolation: SimpleCustomNode,
|
|
|
+ releaseIsolation: SimpleCustomNode,
|
|
|
+ returnLock: SimpleCustomNode,
|
|
|
+ complete: SimpleCustomNode,
|
|
|
+ default: SimpleCustomNode,
|
|
|
+};
|
|
|
+
|
|
|
+/** 作业详情(与接口返回一致) */
|
|
|
+type JobDetail = {
|
|
|
+ id?: number;
|
|
|
+ orderNo?: string;
|
|
|
+ name?: string;
|
|
|
+ initiatorName?: string;
|
|
|
+ initiationTime?: number;
|
|
|
+ description?: string;
|
|
|
+ status?: string;
|
|
|
+ completionTime?: number | null;
|
|
|
+ designId?: number;
|
|
|
+ designName?: string;
|
|
|
+ designContent?: string;
|
|
|
+ workflowWorkNodeDOList?: Array<{
|
|
|
+ id?: number;
|
|
|
+ uuid?: string;
|
|
|
+ nodeName?: string;
|
|
|
+ approvalStatus?: string | number;
|
|
|
+ createTime?: number;
|
|
|
+ workerUserId?: number;
|
|
|
+ initiatorName?: string;
|
|
|
+ [key: string]: any;
|
|
|
+ }>;
|
|
|
+ [key: string]: any;
|
|
|
+};
|
|
|
+
|
|
|
+/** 归档流水项 */
|
|
|
+type ArchiveLogItem = {
|
|
|
+ nodeName?: string;
|
|
|
+ taskNode?: string;
|
|
|
+ nickName?: string;
|
|
|
+ executorName?: string;
|
|
|
+ createTime?: number;
|
|
|
+ taskStartTime?: number;
|
|
|
+ handleTime?: number;
|
|
|
+ approvalStatus?: string | number;
|
|
|
+ taskStatus?: string;
|
|
|
+ taskContent?: string;
|
|
|
+ approvalOpinion?: string;
|
|
|
+ remark?: string;
|
|
|
+ [key: string]: any;
|
|
|
+};
|
|
|
+
|
|
|
+const statusToConclusion = (status?: string) => {
|
|
|
+ const s = String(status || '').toLowerCase();
|
|
|
+ if (s === 'completed' || s === 'done') return { text: '已完成', badge: '正常结束', desc: '作业已按照既定流程流转完毕,所有隔离点已解除,现场确认无误。' };
|
|
|
+ if (s === 'running') return { text: '执行中', badge: '进行中', desc: '作业正在流转中,请关注当前节点进度。' };
|
|
|
+ if (s === 'rejected') return { text: '已退回', badge: '已退回', desc: '作业已被退回,请根据意见修改后重新提交。' };
|
|
|
+ return { text: '待执行', badge: '待执行', desc: '作业已创建,等待流程启动。' };
|
|
|
+};
|
|
|
+
|
|
|
+/** 根据 approvalStatus 判断节点展示状态 */
|
|
|
+const getNodeStepState = (approvalStatus?: string | number): 'done' | 'active' | 'pending' => {
|
|
|
+ if (approvalStatus == null || approvalStatus === '') return 'pending';
|
|
|
+ const s = String(approvalStatus).toLowerCase();
|
|
|
+ if (['approved', 'complete', 'completed', 'done', '3'].includes(s)) return 'done';
|
|
|
+ if (['running', 'processing', '2'].includes(s)) return 'active';
|
|
|
+ return 'pending';
|
|
|
+};
|
|
|
+
|
|
|
+/** 根据 approvalStatus 得到 React Flow 节点状态 */
|
|
|
+const getFlowNodeStatus = (approvalStatus?: string | number): 'completed' | 'in_progress' | 'pending' => {
|
|
|
+ if (approvalStatus == null || approvalStatus === '') return 'pending';
|
|
|
+ const s = String(approvalStatus).toLowerCase();
|
|
|
+ if (['approved', 'complete', 'completed', 'done', '3'].includes(s)) return 'completed';
|
|
|
+ if (['running', 'processing', '2'].includes(s)) return 'in_progress';
|
|
|
+ return 'pending';
|
|
|
+};
|
|
|
+
|
|
|
+/** 从 designContent 得到 nodes/edges,兼容 nodes/edges、nodeList/edgeList、elements 等格式 */
|
|
|
+function parseDesignContentLikeJobDetail(
|
|
|
+ designContent: string | object,
|
|
|
+ workflowWorkNodeDOList: Array<{ uuid?: string; nodeName?: string; approvalStatus?: string | number; [key: string]: any }>
|
|
|
+): { nodes: Node[]; edges: Edge[] } | null {
|
|
|
+ try {
|
|
|
+ const jsonData = typeof designContent === 'string' ? JSON.parse(designContent) : designContent;
|
|
|
+ if (!jsonData || typeof jsonData !== 'object') return null;
|
|
|
+ let rawNodes: any[] = jsonData.nodes ?? jsonData.nodeList ?? [];
|
|
|
+ let rawEdges: any[] = jsonData.edges ?? jsonData.edgeList ?? [];
|
|
|
+ if (!Array.isArray(rawNodes) || !Array.isArray(rawEdges)) {
|
|
|
+ const elements = jsonData.elements;
|
|
|
+ if (Array.isArray(elements)) {
|
|
|
+ rawNodes = elements.filter((el: any) => el.id != null && el.source == null && el.target == null);
|
|
|
+ rawEdges = elements.filter((el: any) => el.source != null && el.target != null);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if (!Array.isArray(rawNodes) || !Array.isArray(rawEdges) || rawNodes.length === 0) return null;
|
|
|
+
|
|
|
+ const nodeMap = new Map<string, { nodeName?: string; approvalStatus?: string | number; type?: string; nodeIcon?: string; id?: number; [key: string]: any }>();
|
|
|
+ workflowWorkNodeDOList.forEach((n) => { if (n.uuid) nodeMap.set(String(n.uuid), n); });
|
|
|
+ const validTypes = new Set(['createJob', 'confirm', 'review', 'inputInfo', 'isolation', 'releaseIsolation', 'returnLock', 'complete']);
|
|
|
+ const convertedNodes: Node[] = rawNodes
|
|
|
+ .map((node: any) => {
|
|
|
+ const nodeId = node.uuid ?? node.id;
|
|
|
+ if (nodeId == null || nodeId === '') return null;
|
|
|
+ const id = String(nodeId);
|
|
|
+ const workNode = nodeMap.get(id);
|
|
|
+ const nodeData = node.data || {};
|
|
|
+ const nodeName = workNode?.nodeName || nodeData.label || node.nodeName || node.label || '节点';
|
|
|
+ const status = getFlowNodeStatus(workNode?.approvalStatus);
|
|
|
+ const rawType = workNode?.type || node.type || nodeData.type || 'createJob';
|
|
|
+ const type = validTypes.has(rawType) ? rawType : 'default';
|
|
|
+ const icon = workNode?.nodeIcon ?? nodeData.icon ?? node.nodeIcon ?? '';
|
|
|
+ const displayNodeId = workNode?.id != null ? String(workNode.id) : (nodeData.nodeId || '');
|
|
|
+ const pos = node.position;
|
|
|
+ const position = pos && typeof pos === 'object' && typeof pos.x === 'number' && typeof pos.y === 'number'
|
|
|
+ ? { x: pos.x, y: pos.y }
|
|
|
+ : { x: 0, y: 0 };
|
|
|
+ return {
|
|
|
+ id,
|
|
|
+ type,
|
|
|
+ position,
|
|
|
+ data: {
|
|
|
+ label: nodeName,
|
|
|
+ nodeName,
|
|
|
+ status,
|
|
|
+ type: rawType,
|
|
|
+ icon,
|
|
|
+ nodeId: displayNodeId,
|
|
|
+ },
|
|
|
+ };
|
|
|
+ })
|
|
|
+ .filter((n): n is Node => n != null);
|
|
|
+ if (convertedNodes.length === 0) return null;
|
|
|
+
|
|
|
+ const nodeIdSet = new Set(convertedNodes.map((n) => n.id));
|
|
|
+ const convertedEdges: Edge[] = rawEdges
|
|
|
+ .map((edge: any, i: number) => ({
|
|
|
+ id: edge.id ?? `e-${i}`,
|
|
|
+ source: String(edge.source),
|
|
|
+ target: String(edge.target),
|
|
|
+ sourceHandle: edge.sourceHandle,
|
|
|
+ targetHandle: edge.targetHandle,
|
|
|
+ type: (edge.type || 'straight') as any,
|
|
|
+ style: { strokeWidth: 2, stroke: '#000000' },
|
|
|
+ markerStart: { type: 'arrowclosed' as any, color: '#000000' },
|
|
|
+ }))
|
|
|
+ .filter((e) => nodeIdSet.has(e.source) && nodeIdSet.has(e.target));
|
|
|
+ return { nodes: convertedNodes, edges: convertedEdges };
|
|
|
+ } catch {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+/** 仅根据 workflowWorkNodeDOList 构建简易拓扑(无 designContent 时的兜底) */
|
|
|
+function buildFlowFromNodeList(
|
|
|
+ workflowWorkNodeDOList?: Array<{ uuid?: string; nodeName?: string; approvalStatus?: string | number; parentUuid?: string; childrenUuid?: string; position?: string; createTime?: number; [key: string]: any }>
|
|
|
+): { nodes: Node[]; edges: Edge[] } {
|
|
|
+ const list = workflowWorkNodeDOList ?? [];
|
|
|
+ if (list.length === 0) return { nodes: [], edges: [] };
|
|
|
+ const sorted = [...list].sort((a, b) => (a.createTime ?? 0) - (b.createTime ?? 0));
|
|
|
+ const gap = 180;
|
|
|
+ const validTypes = new Set(['createJob', 'confirm', 'review', 'inputInfo', 'isolation', 'releaseIsolation', 'returnLock', 'complete']);
|
|
|
+ const nodes: Node[] = sorted
|
|
|
+ .filter((n) => n.uuid)
|
|
|
+ .map((n, i) => {
|
|
|
+ const id = String(n.uuid);
|
|
|
+ let position = { x: 240, y: 60 + i * gap };
|
|
|
+ if (n.position) {
|
|
|
+ try {
|
|
|
+ const p = typeof n.position === 'string' ? JSON.parse(n.position) : n.position;
|
|
|
+ if (p && typeof p.x === 'number' && typeof p.y === 'number') position = p;
|
|
|
+ } catch {}
|
|
|
+ }
|
|
|
+ const rawType = (n as any).type || 'createJob';
|
|
|
+ const type = validTypes.has(rawType) ? rawType : 'default';
|
|
|
+ const status = getFlowNodeStatus(n.approvalStatus);
|
|
|
+ const label = n.nodeName ?? '节点';
|
|
|
+ return {
|
|
|
+ id,
|
|
|
+ type,
|
|
|
+ position,
|
|
|
+ data: {
|
|
|
+ label,
|
|
|
+ nodeName: label,
|
|
|
+ status,
|
|
|
+ type: rawType,
|
|
|
+ icon: (n as any).nodeIcon ?? '',
|
|
|
+ nodeId: n.id != null ? String(n.id) : '',
|
|
|
+ },
|
|
|
+ };
|
|
|
+ });
|
|
|
+ const edgeIds = new Set(nodes.map((n) => n.id));
|
|
|
+ const edges: Edge[] = [];
|
|
|
+ list.forEach((n) => {
|
|
|
+ if (!n.uuid || !n.parentUuid) return;
|
|
|
+ const parent = String(n.parentUuid).trim();
|
|
|
+ if (parent && edgeIds.has(parent) && edgeIds.has(n.uuid)) {
|
|
|
+ edges.push({
|
|
|
+ id: `e-${parent}-${n.uuid}`,
|
|
|
+ source: parent,
|
|
|
+ target: n.uuid,
|
|
|
+ style: { strokeWidth: 2, stroke: '#000' },
|
|
|
+ markerStart: { type: 'arrowclosed' as any, color: '#000000' },
|
|
|
+ });
|
|
|
+ }
|
|
|
+ });
|
|
|
+ if (edges.length === 0 && nodes.length > 1) {
|
|
|
+ for (let i = 0; i < nodes.length - 1; i++) {
|
|
|
+ edges.push({
|
|
|
+ id: `e-fallback-${i}`,
|
|
|
+ source: nodes[i].id,
|
|
|
+ target: nodes[i + 1].id,
|
|
|
+ style: { strokeWidth: 2, stroke: '#000' },
|
|
|
+ markerStart: { type: 'arrowclosed' as any, color: '#000000' },
|
|
|
+ });
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return { nodes, edges };
|
|
|
+}
|
|
|
+
|
|
|
+export default function WorkJobArchiveReport() {
|
|
|
+ const [searchParams] = useSearchParams();
|
|
|
+ const navigate = useNavigate();
|
|
|
+ const jobId = searchParams.get('id');
|
|
|
+
|
|
|
+ const [jobDetail, setJobDetail] = useState<JobDetail | null>(null);
|
|
|
+ const [archiveList, setArchiveList] = useState<ArchiveLogItem[]>([]);
|
|
|
+ const [flowNodes, setFlowNodes, onFlowNodesChange] = useNodesState([]);
|
|
|
+ const [flowEdges, setFlowEdges, onFlowEdgesChange] = useEdgesState([]);
|
|
|
+ const [loading, setLoading] = useState(true);
|
|
|
+ const [error, setError] = useState<string | null>(null);
|
|
|
+ const [exportingPdf, setExportingPdf] = useState(false);
|
|
|
+ const page1Ref = useRef<HTMLDivElement>(null);
|
|
|
+ const page2Ref = useRef<HTMLDivElement>(null);
|
|
|
+
|
|
|
+ const watermarkStyle = useWatermarkStyle();
|
|
|
+
|
|
|
+ useEffect(() => {
|
|
|
+ if (!jobId) {
|
|
|
+ setLoading(false);
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ const id = Number(jobId);
|
|
|
+ if (Number.isNaN(id)) {
|
|
|
+ setLoading(false);
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ setLoading(true);
|
|
|
+ setError(null);
|
|
|
+ setFlowNodes([]);
|
|
|
+ setFlowEdges([]);
|
|
|
+
|
|
|
+ const load = async () => {
|
|
|
+ try {
|
|
|
+ const response = await workJobApi.selectWorkflowWorkById(id);
|
|
|
+ const raw = (response as any)?.data ?? response;
|
|
|
+ const data = (raw && typeof raw === 'object' && raw.data !== undefined) ? raw.data : raw;
|
|
|
+ setJobDetail(data || null);
|
|
|
+
|
|
|
+ const [logRes] = await Promise.all([
|
|
|
+ workHandleApi.getWorkflowWorkLogPage({ workId: id }),
|
|
|
+ ]);
|
|
|
+ const logData = (logRes as any)?.data ?? logRes;
|
|
|
+ const list = Array.isArray(logData) ? logData : logData?.list ?? logData?.records ?? [];
|
|
|
+ setArchiveList(Array.isArray(list) ? list : []);
|
|
|
+
|
|
|
+ const nodeList = Array.isArray(data?.workflowWorkNodeDOList) ? data.workflowWorkNodeDOList : [];
|
|
|
+ let designContent: string | object | null = data?.designContent ?? data?.content ?? null;
|
|
|
+ if (!designContent && data?.designId) {
|
|
|
+ try {
|
|
|
+ const designResponse = await workflowDesignApi.selectWorkflowDesignById(data.designId);
|
|
|
+ const designRaw = (designResponse as any)?.data ?? designResponse;
|
|
|
+ const designData = (designRaw && designRaw.data !== undefined) ? designRaw.data : designRaw;
|
|
|
+ const content = designData?.content ?? designData?.designContent;
|
|
|
+ if (content != null) designContent = content;
|
|
|
+ } catch (_) {}
|
|
|
+ }
|
|
|
+
|
|
|
+ if (designContent != null) {
|
|
|
+ const parsed = parseDesignContentLikeJobDetail(designContent, nodeList);
|
|
|
+ if (parsed && parsed.nodes.length > 0) {
|
|
|
+ setFlowNodes(parsed.nodes);
|
|
|
+ setFlowEdges(parsed.edges);
|
|
|
+ setLoading(false);
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ const fallback = buildFlowFromNodeList(nodeList);
|
|
|
+ if (fallback.nodes.length > 0) {
|
|
|
+ setFlowNodes(fallback.nodes);
|
|
|
+ setFlowEdges(fallback.edges);
|
|
|
+ }
|
|
|
+ } catch (err: any) {
|
|
|
+ setError(err?.message || '加载失败');
|
|
|
+ setJobDetail(null);
|
|
|
+ setArchiveList([]);
|
|
|
+ } finally {
|
|
|
+ setLoading(false);
|
|
|
+ }
|
|
|
+ };
|
|
|
+ load();
|
|
|
+ }, [jobId, setFlowNodes, setFlowEdges]);
|
|
|
+
|
|
|
+ // 当 jobDetail 已有节点列表但流程仍为空时,用节点列表构建拓扑(兜底)
|
|
|
+ useEffect(() => {
|
|
|
+ if (!jobDetail || flowNodes.length > 0) return;
|
|
|
+ const list = jobDetail.workflowWorkNodeDOList;
|
|
|
+ if (!Array.isArray(list) || list.length === 0) return;
|
|
|
+ const fallback = buildFlowFromNodeList(list);
|
|
|
+ if (fallback.nodes.length > 0) {
|
|
|
+ setFlowNodes(fallback.nodes);
|
|
|
+ setFlowEdges(fallback.edges);
|
|
|
+ }
|
|
|
+ }, [jobDetail, flowNodes.length, setFlowNodes, setFlowEdges]);
|
|
|
+
|
|
|
+ const handlePrint = () => {
|
|
|
+ document.documentElement.classList.add('print-archive-only');
|
|
|
+ document.body.classList.add('print-archive-only');
|
|
|
+ window.print();
|
|
|
+ window.onafterprint = () => {
|
|
|
+ document.documentElement.classList.remove('print-archive-only');
|
|
|
+ document.body.classList.remove('print-archive-only');
|
|
|
+ };
|
|
|
+ };
|
|
|
+
|
|
|
+ const handleExportPdf = async () => {
|
|
|
+ if (exportingPdf) return;
|
|
|
+ const el1 = page1Ref.current ?? document.querySelector<HTMLDivElement>('.work-job-archive-report .page-sheet.page-1');
|
|
|
+ const el2 = page2Ref.current ?? document.querySelector<HTMLDivElement>('.work-job-archive-report .page-sheet.page-2');
|
|
|
+ if (!el1 || !el2) {
|
|
|
+ toast.error('无法获取页面内容,请稍后重试');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ setExportingPdf(true);
|
|
|
+ const loadingId = toast.loading('正在生成 PDF...');
|
|
|
+ try {
|
|
|
+ const [html2canvas, { jsPDF }, htmlToImage] = await Promise.all([
|
|
|
+ import('html2canvas'),
|
|
|
+ import('jspdf'),
|
|
|
+ import('html-to-image').then((m) => m).catch(() => null),
|
|
|
+ ]);
|
|
|
+ const A4_W = 210;
|
|
|
+ const A4_H = 297;
|
|
|
+ const scale = 2;
|
|
|
+ /** 将 data URL 转为 Canvas,便于统一用 fitToA4 与 addImage */
|
|
|
+ const dataUrlToCanvas = (dataUrl: string): Promise<HTMLCanvasElement> =>
|
|
|
+ new Promise((resolve, reject) => {
|
|
|
+ const img = new Image();
|
|
|
+ img.crossOrigin = 'anonymous';
|
|
|
+ img.onload = () => {
|
|
|
+ const c = document.createElement('canvas');
|
|
|
+ c.width = img.naturalWidth;
|
|
|
+ c.height = img.naturalHeight;
|
|
|
+ const ctx = c.getContext('2d');
|
|
|
+ if (!ctx) { reject(new Error('getContext failed')); return; }
|
|
|
+ ctx.drawImage(img, 0, 0);
|
|
|
+ resolve(c);
|
|
|
+ };
|
|
|
+ img.onerror = () => reject(new Error('Image load failed'));
|
|
|
+ img.src = dataUrl;
|
|
|
+ });
|
|
|
+ const replaceUnsupportedColors = (cssText: string): string => {
|
|
|
+ let s = cssText;
|
|
|
+ const replaceBalanced = (open: string, replacement: string): boolean => {
|
|
|
+ const start = s.indexOf(open);
|
|
|
+ if (start === -1) return false;
|
|
|
+ const parenStart = start + open.length - 1;
|
|
|
+ let depth = 1;
|
|
|
+ let i = parenStart + 1;
|
|
|
+ while (i < s.length && depth > 0) {
|
|
|
+ if (s[i] === '(') depth++;
|
|
|
+ else if (s[i] === ')') depth--;
|
|
|
+ i++;
|
|
|
+ }
|
|
|
+ if (depth === 0) {
|
|
|
+ s = s.slice(0, start) + replacement + s.slice(i);
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+ return false;
|
|
|
+ };
|
|
|
+ while (replaceBalanced('color-mix(', 'rgb(200,200,200)') || replaceBalanced('oklch(', 'rgb(80,80,80)')) {
|
|
|
+ /* repeat until no more */
|
|
|
+ }
|
|
|
+ return s;
|
|
|
+ };
|
|
|
+ /** 将拓扑图内图片转为 data URL(用 canvas 绘制,兼容 blob/同源),确保导出 PDF 时图标能正确渲染;返回恢复函数 */
|
|
|
+ const preloadFlowImagesToDataUrls = async (container: HTMLElement): Promise<() => void> => {
|
|
|
+ const imgs = container.querySelectorAll<HTMLImageElement>('img[src]');
|
|
|
+ const restores: Array<{ el: HTMLImageElement; original: string }> = [];
|
|
|
+ const toDataUrl = (img: HTMLImageElement): boolean => {
|
|
|
+ try {
|
|
|
+ if (!img.naturalWidth || !img.naturalHeight) return false;
|
|
|
+ const c = document.createElement('canvas');
|
|
|
+ c.width = img.naturalWidth;
|
|
|
+ c.height = img.naturalHeight;
|
|
|
+ const ctx = c.getContext('2d');
|
|
|
+ if (!ctx) return false;
|
|
|
+ ctx.drawImage(img, 0, 0);
|
|
|
+ const dataUrl = c.toDataURL('image/png');
|
|
|
+ restores.push({ el: img, original: img.src });
|
|
|
+ img.src = dataUrl;
|
|
|
+ return true;
|
|
|
+ } catch {
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ };
|
|
|
+ await Promise.all(
|
|
|
+ Array.from(imgs).map((img) => {
|
|
|
+ const src = img.getAttribute('src') || img.src;
|
|
|
+ if (!src || src.startsWith('data:')) return Promise.resolve();
|
|
|
+ return new Promise<void>((resolve) => {
|
|
|
+ if (img.complete && img.naturalWidth) {
|
|
|
+ toDataUrl(img);
|
|
|
+ resolve();
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ img.onload = () => { toDataUrl(img); resolve(); };
|
|
|
+ img.onerror = () => resolve();
|
|
|
+ });
|
|
|
+ })
|
|
|
+ );
|
|
|
+ return () => restores.forEach(({ el, original }) => { el.src = original; });
|
|
|
+ };
|
|
|
+
|
|
|
+ const stripOklch = (doc: Document) => {
|
|
|
+ doc.querySelectorAll('style').forEach((style) => {
|
|
|
+ if (style.textContent && (style.textContent.includes('oklch') || style.textContent.includes('color-mix'))) {
|
|
|
+ style.textContent = replaceUnsupportedColors(style.textContent);
|
|
|
+ }
|
|
|
+ });
|
|
|
+ try {
|
|
|
+ Array.from(doc.styleSheets).forEach((sheet) => {
|
|
|
+ try {
|
|
|
+ const rules = sheet.cssRules ?? (sheet as CSSStyleSheet).rules;
|
|
|
+ if (!rules) return;
|
|
|
+ for (let i = 0; i < rules.length; i++) {
|
|
|
+ const rule = rules[i] as CSSStyleRule;
|
|
|
+ if (rule.style?.cssText && (rule.style.cssText.includes('oklch') || rule.style.cssText.includes('color-mix'))) {
|
|
|
+ rule.style.cssText = replaceUnsupportedColors(rule.style.cssText);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ } catch {
|
|
|
+ /* 跨域或无法访问的样式表忽略 */
|
|
|
+ }
|
|
|
+ });
|
|
|
+ } catch {
|
|
|
+ /* styleSheets 不可用则忽略 */
|
|
|
+ }
|
|
|
+ doc.querySelectorAll('[style]').forEach((el) => {
|
|
|
+ const elWithStyle = el as HTMLElement;
|
|
|
+ if (elWithStyle.style?.cssText && (elWithStyle.style.cssText.includes('oklch') || elWithStyle.style.cssText.includes('color-mix'))) {
|
|
|
+ elWithStyle.style.cssText = replaceUnsupportedColors(elWithStyle.style.cssText);
|
|
|
+ }
|
|
|
+ const attr = el.getAttribute('style');
|
|
|
+ if (attr && (attr.includes('oklch') || attr.includes('color-mix'))) {
|
|
|
+ el.setAttribute('style', replaceUnsupportedColors(attr));
|
|
|
+ }
|
|
|
+ });
|
|
|
+ };
|
|
|
+ const opts = {
|
|
|
+ scale,
|
|
|
+ useCORS: true,
|
|
|
+ allowTaint: true,
|
|
|
+ backgroundColor: '#ffffff',
|
|
|
+ logging: false,
|
|
|
+ onclone: (clonedDoc: Document) => {
|
|
|
+ stripOklch(clonedDoc);
|
|
|
+ // 导出时隐藏连接点与控制按钮,避免拓扑图在 PDF 中渲染混乱
|
|
|
+ const exportStyle = clonedDoc.createElement('style');
|
|
|
+ exportStyle.textContent = [
|
|
|
+ '.react-flow__handle { display: none !important; }',
|
|
|
+ '.react-flow__controls { display: none !important; }',
|
|
|
+ '.react-flow__viewport { will-change: auto !important; }',
|
|
|
+ /* 避免连接线被裁切,确保 SVG 边线完整导出 */
|
|
|
+ '.archive-flow-print-wrapper, .archive-flow-inner, .react-flow__viewport, .react-flow__edges, .react-flow__renderer { overflow: visible !important; }',
|
|
|
+ '.react-flow__edges svg { overflow: visible !important; }',
|
|
|
+ ].join('\n');
|
|
|
+ clonedDoc.head.appendChild(exportStyle);
|
|
|
+ /* 为边线 SVG 设置宽高,改善 html2canvas 对 SVG 的渲染 */
|
|
|
+ clonedDoc.querySelectorAll('.react-flow__edges svg').forEach((svg) => {
|
|
|
+ const el = svg as SVGSVGElement;
|
|
|
+ const b = el.getBoundingClientRect();
|
|
|
+ if (b.width > 0 && b.height > 0) {
|
|
|
+ el.setAttribute('width', String(Math.ceil(b.width)));
|
|
|
+ el.setAttribute('height', String(Math.ceil(b.height)));
|
|
|
+ }
|
|
|
+ });
|
|
|
+ },
|
|
|
+ };
|
|
|
+ const canvas1 = await html2canvas.default(el1, opts);
|
|
|
+
|
|
|
+ // 截取第二页前:统一把拓扑图内图片转为 data URL(两种导出方式都生效),并隐藏手柄/控制栏(不改 overflow,避免 PDF 中位置偏移)
|
|
|
+ const restoreFlowImages = await preloadFlowImagesToDataUrls(el2);
|
|
|
+ const exportFlowStyle = document.createElement('style');
|
|
|
+ exportFlowStyle.id = 'pdf-export-flow-style';
|
|
|
+ exportFlowStyle.textContent = '.react-flow__handle, .react-flow__controls { display: none !important; }';
|
|
|
+ document.head.appendChild(exportFlowStyle);
|
|
|
+ await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
|
|
|
+
|
|
|
+ let canvas2: HTMLCanvasElement;
|
|
|
+ try {
|
|
|
+ if (htmlToImage?.toPng) {
|
|
|
+ const dataUrl2 = await (htmlToImage as { toPng: (el: HTMLElement, o?: { pixelRatio?: number; backgroundColor?: string }) => Promise<string> }).toPng(el2, { pixelRatio: 2, backgroundColor: '#ffffff' });
|
|
|
+ canvas2 = await dataUrlToCanvas(dataUrl2);
|
|
|
+ } else {
|
|
|
+ canvas2 = await html2canvas.default(el2, opts);
|
|
|
+ }
|
|
|
+ } finally {
|
|
|
+ restoreFlowImages();
|
|
|
+ document.getElementById('pdf-export-flow-style')?.remove();
|
|
|
+ }
|
|
|
+ const pdf = new jsPDF({ orientation: 'portrait', unit: 'mm', format: 'a4' });
|
|
|
+ const fitToA4 = (canvas: HTMLCanvasElement) => {
|
|
|
+ const w = canvas.width;
|
|
|
+ const h = canvas.height;
|
|
|
+ const r = w / h;
|
|
|
+ if (A4_W / A4_H >= r) {
|
|
|
+ return { w: A4_H * r, h: A4_H };
|
|
|
+ }
|
|
|
+ return { w: A4_W, h: A4_W / r };
|
|
|
+ };
|
|
|
+ const size1 = fitToA4(canvas1);
|
|
|
+ pdf.addImage(canvas1.toDataURL('image/jpeg', 0.92), 'JPEG', 0, 0, size1.w, size1.h);
|
|
|
+ const size2 = fitToA4(canvas2);
|
|
|
+ pdf.addPage();
|
|
|
+ pdf.addImage(canvas2.toDataURL('image/jpeg', 0.92), 'JPEG', 0, 0, size2.w, size2.h);
|
|
|
+ const fileName = `作业归档报告_${jobDetail?.orderNo ?? jobDetail?.code ?? jobId ?? 'export'}.pdf`;
|
|
|
+ pdf.save(fileName);
|
|
|
+ toast.success('PDF 导出成功', { id: loadingId });
|
|
|
+ } catch (e: any) {
|
|
|
+ const raw = e?.message || e?.toString?.() || '';
|
|
|
+ const msg = /fetch|import|dynamic/i.test(raw)
|
|
|
+ ? 'PDF 导出失败:请先在项目目录执行 npm install html2canvas jspdf 安装依赖'
|
|
|
+ : raw || 'PDF 导出失败';
|
|
|
+ toast.error(msg, { id: loadingId });
|
|
|
+ console.error('[导出PDF]', e);
|
|
|
+ } finally {
|
|
|
+ setExportingPdf(false);
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ const handleBack = () => navigate(`/work-job/detail${jobId ? `?id=${jobId}` : ''}`);
|
|
|
+
|
|
|
+ const conclusion = jobDetail ? statusToConclusion(jobDetail.status) : statusToConclusion('');
|
|
|
+ const nodeList = jobDetail?.workflowWorkNodeDOList ?? [];
|
|
|
+ const sortedNodes = [...nodeList].sort((a, b) => (a.createTime ?? 0) - (b.createTime ?? 0));
|
|
|
+ const currentNodeId = jobDetail?.currentNodeId ?? null;
|
|
|
+
|
|
|
+ if (loading) {
|
|
|
+ return (
|
|
|
+ <div className="work-job-archive-report" style={{ padding: '2rem', textAlign: 'center', color: '#64748b' }}>
|
|
|
+ 加载中...
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+ }
|
|
|
+ if (error) {
|
|
|
+ return (
|
|
|
+ <div className="work-job-archive-report" style={{ padding: '2rem', textAlign: 'center', color: '#94a3b8' }}>
|
|
|
+ {error}
|
|
|
+ <div style={{ marginTop: '1rem' }}>
|
|
|
+ <button type="button" onClick={handleBack} className="back-btn" style={{ color: '#334155', borderColor: '#cbd5e1' }}>
|
|
|
+ 返回
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+ }
|
|
|
+ if (!jobId) {
|
|
|
+ return (
|
|
|
+ <div className="work-job-archive-report" style={{ padding: '2rem', textAlign: 'center', color: '#94a3b8' }}>
|
|
|
+ 请从作业详情页进入归档
|
|
|
+ <div style={{ marginTop: '1rem' }}>
|
|
|
+ <button type="button" onClick={() => navigate('/dashboard')} className="back-btn" style={{ color: '#334155', borderColor: '#cbd5e1' }}>
|
|
|
+ 返回
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ return (
|
|
|
+ <div className="work-job-archive-report">
|
|
|
+
|
|
|
+ {/* 顶部操作栏 */}
|
|
|
+ <div className="archive-toolbar no-print">
|
|
|
+ <h2>作业归档详情页</h2>
|
|
|
+ <div className="archive-toolbar-actions">
|
|
|
+ <button type="button" onClick={handleExportPdf} className="export-pdf-btn" disabled={exportingPdf}>
|
|
|
+ <FileDown style={{ width: 16, height: 16 }} />
|
|
|
+ {exportingPdf ? '导出中...' : '导出PDF'}
|
|
|
+ </button>
|
|
|
+ <button type="button" onClick={handlePrint} className="print-btn">
|
|
|
+ <Printer style={{ width: 16, height: 16 }} />
|
|
|
+ 打印归档
|
|
|
+ </button>
|
|
|
+ <button type="button" onClick={handleBack} className="back-btn">
|
|
|
+ <ArrowLeft style={{ width: 16, height: 16 }} />
|
|
|
+ 返回
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {/* ================= 第 1 页:基本信息与结果(与 test1 一致) ================= */}
|
|
|
+ <div ref={page1Ref} className="page-sheet watermark-bg page-1" style={watermarkStyle}>
|
|
|
+ <div className="top-bar" />
|
|
|
+ <header className="page-header">
|
|
|
+ <div>
|
|
|
+ <h1>作业归档报告</h1>
|
|
|
+ <p className="subtitle">Archiving Report for Complex Workflow</p>
|
|
|
+ </div>
|
|
|
+ <div className="doc-no-wrap">
|
|
|
+ <div className="doc-no-label">单据编号</div>
|
|
|
+ <div className="doc-no">{jobDetail?.orderNo ?? jobDetail?.code ?? '-'}</div>
|
|
|
+ </div>
|
|
|
+ </header>
|
|
|
+
|
|
|
+ <div className="info-grid">
|
|
|
+ <div className="info-box">
|
|
|
+ <h3>基本信息</h3>
|
|
|
+ <div className="info-row"><span className="info-label">作业名称</span><span className="info-value">{jobDetail?.name ?? '-'}</span></div>
|
|
|
+ <div className="info-row"><span className="info-label">发起人</span><span className="info-value">{jobDetail?.initiatorName ?? jobDetail?.initiator ?? '-'}</span></div>
|
|
|
+ <div className="info-row"><span className="info-label">发起时间</span><span className="info-value">{jobDetail?.initiationTime ? formatDateWithFormat(jobDetail.initiationTime) : '-'}</span></div>
|
|
|
+ </div>
|
|
|
+ <div className="info-box">
|
|
|
+ <h3>执行结论</h3>
|
|
|
+ <div className="conclusion-row">
|
|
|
+ <span className="conclusion-status">{conclusion.text}</span>
|
|
|
+ <span className="conclusion-badge">{conclusion.badge}</span>
|
|
|
+ </div>
|
|
|
+ <p className="conclusion-desc">{conclusion.desc}</p>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <section className="section">
|
|
|
+ <div className="section-header">
|
|
|
+ <h3 className="section-title">流程执行摘要</h3>
|
|
|
+ </div>
|
|
|
+ <div className="summary-bar summary-steps">
|
|
|
+ <div className="summary-grid">
|
|
|
+ {(() => {
|
|
|
+ const maxSteps = 7;
|
|
|
+ const nodes = sortedNodes.slice(0, maxSteps);
|
|
|
+ const getState = (i: number) => (nodes[i] ? getNodeStepState(nodes[i].approvalStatus) : 'pending' as const);
|
|
|
+ const connDone = (i: number) => getState(i) !== 'pending' || getState(i + 1) !== 'pending';
|
|
|
+ const el: React.ReactNode[] = [];
|
|
|
+ for (let i = 0; i < maxSteps; i++) {
|
|
|
+ const state = getState(i);
|
|
|
+ const iconClass = state === 'done' ? 'summary-step-done' : state === 'active' ? 'summary-step-active summary-step-icon-active' : 'summary-step-pending';
|
|
|
+ el.push(
|
|
|
+ <div key={`icon-${i}`} className={`summary-step-icon ${iconClass}`}>
|
|
|
+ {state === 'done' && <Check size={18} strokeWidth={2.5} />}
|
|
|
+ {state === 'active' && <Clock size={18} strokeWidth={2.5} />}
|
|
|
+ {state === 'pending' && <span className="summary-step-dot" />}
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+ if (i < maxSteps - 1) {
|
|
|
+ el.push(<div key={`conn-${i}`} className={`summary-connector ${connDone(i) ? 'summary-connector-done' : 'summary-connector-pending'}`} />);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ for (let i = 0; i < maxSteps; i++) {
|
|
|
+ const col = 1 + i * 2;
|
|
|
+ const label = nodes[i]?.nodeName ?? '';
|
|
|
+ el.push(<div key={`label-${i}`} className="summary-label-cell" style={{ gridColumn: col }}><span className="summary-step-label">{label || '\u00A0'}</span></div>);
|
|
|
+ }
|
|
|
+ return el;
|
|
|
+ })()}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <p className="summary-footnote">*流程分支详情请翻阅附录页完整拓扑图</p>
|
|
|
+ </section>
|
|
|
+
|
|
|
+ <section className="record-section">
|
|
|
+ <div className="record-log-title">
|
|
|
+ <span className="record-log-title-bar" />
|
|
|
+ <h3 className="record-log-title-text">归档流水日志 <span className="record-log-title-en">/ ARCHIVE LOG</span></h3>
|
|
|
+ </div>
|
|
|
+ <div className="record-table-wrap">
|
|
|
+ <table className="record-table record-table-log">
|
|
|
+ <thead>
|
|
|
+ <tr>
|
|
|
+ <th className="col-no">#</th>
|
|
|
+ <th>任务名称</th>
|
|
|
+ <th>操作人</th>
|
|
|
+ <th>操作时间</th>
|
|
|
+ <th>状态</th>
|
|
|
+ <th>操作记录</th>
|
|
|
+ </tr>
|
|
|
+ </thead>
|
|
|
+ <tbody>
|
|
|
+ {archiveList.length === 0 ? (
|
|
|
+ <tr><td colSpan={6} style={{ textAlign: 'center', color: '#94a3b8', padding: '1rem' }}>暂无流水记录</td></tr>
|
|
|
+ ) : (
|
|
|
+ archiveList.map((log, idx) => {
|
|
|
+ const nodeName = log.nodeName ?? log.taskNode ?? '-';
|
|
|
+ const operator = log.nickName ?? log.executorName ?? '-';
|
|
|
+ const initial = (operator && operator !== '-') ? operator.trim().charAt(0).toUpperCase() : '?';
|
|
|
+ const time = log.createTime ?? log.taskStartTime ?? log.handleTime;
|
|
|
+ const timeStr = time != null ? formatDateWithFormat(time) : '-';
|
|
|
+ const st = String(log.approvalStatus ?? log.taskStatus ?? '').toLowerCase();
|
|
|
+ const isProcessing = ['running', 'processing', '2'].includes(st);
|
|
|
+ const statusText = isProcessing ? '处理中' : (st.includes('approve') || st === '3' ? '通过' : st ? '启动' : '-');
|
|
|
+ const remark = log.taskContent ?? log.approvalOpinion ?? log.remark ?? '';
|
|
|
+ return (
|
|
|
+ <tr key={log.id ?? idx}>
|
|
|
+ <td className="col-no">{idx + 1}</td>
|
|
|
+ <td>{nodeName}</td>
|
|
|
+ <td>
|
|
|
+ <span className="cell-operator">
|
|
|
+ <Avatar className="archive-log-avatar">
|
|
|
+ {(log as any).avatar && <AvatarImage src={(log as any).avatar} alt={operator} />}
|
|
|
+ <AvatarFallback className={isProcessing ? 'avatar-active' : ''}>{initial}</AvatarFallback>
|
|
|
+ </Avatar>
|
|
|
+ {operator}
|
|
|
+ </span>
|
|
|
+ </td>
|
|
|
+ <td className="cell-time">{timeStr}</td>
|
|
|
+ <td><span className={`cell-status ${isProcessing ? 'status-processing' : 'status-done'}`}><span className="status-dot" /> {statusText}</span></td>
|
|
|
+ <td>{remark}</td>
|
|
|
+ </tr>
|
|
|
+ );
|
|
|
+ })
|
|
|
+ )}
|
|
|
+ </tbody>
|
|
|
+ </table>
|
|
|
+ </div>
|
|
|
+ </section>
|
|
|
+
|
|
|
+ <div className="page-num">第 1 页 / 共 2 页</div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {/* ================= 第 2 页:完整作业流程拓扑图(React Flow,可拖拽;打印时整体缩小) ================= */}
|
|
|
+ <div ref={page2Ref} className="page-sheet watermark-bg page-2" style={watermarkStyle}>
|
|
|
+ <div className="page-2-header">
|
|
|
+ <h2>附录:完整作业流程拓扑图</h2>
|
|
|
+ <span className="doc-ref">单据:{jobDetail?.orderNo ?? jobDetail?.code ?? '-'}</span>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div className="flow-wrap flow-container archive-flow-print-wrapper">
|
|
|
+ {flowNodes.length > 0 ? (
|
|
|
+ <ReactFlowProvider>
|
|
|
+ <style>{`
|
|
|
+ .react-flow__attribution { display: none !important; }
|
|
|
+ .react-flow__arrowhead { fill: #000000 !important; }
|
|
|
+ .react-flow__edge-path { stroke: #000000 !important; }
|
|
|
+ .react-flow__edge path { stroke: #000000 !important; }
|
|
|
+ `}</style>
|
|
|
+ <div className="archive-flow-inner">
|
|
|
+ <ReactFlow
|
|
|
+ key={`archive-flow-${flowNodes.length}`}
|
|
|
+ nodes={flowNodes}
|
|
|
+ edges={flowEdges}
|
|
|
+ onNodesChange={onFlowNodesChange}
|
|
|
+ onEdgesChange={onFlowEdgesChange}
|
|
|
+ nodeTypes={archiveNodeTypes}
|
|
|
+ fitView
|
|
|
+ fitViewOptions={{ padding: 0.35 }}
|
|
|
+ nodesDraggable={true}
|
|
|
+ nodesConnectable={false}
|
|
|
+ elementsSelectable={true}
|
|
|
+ defaultEdgeOptions={{
|
|
|
+ style: { strokeWidth: 2, stroke: '#000000' },
|
|
|
+ markerStart: { type: 'arrowclosed' as any, color: '#000000' },
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ <Background variant={BackgroundVariant.Dots} gap={16} size={1} color="#000000" />
|
|
|
+ </ReactFlow>
|
|
|
+ </div>
|
|
|
+ </ReactFlowProvider>
|
|
|
+ ) : (
|
|
|
+ <div className="flex items-center justify-center h-full text-gray-400 text-sm">暂无流程拓扑数据(节点数:{jobDetail?.workflowWorkNodeDOList?.length ?? 0})</div>
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div className="flow-legend">
|
|
|
+ <div className="flow-legend-item"><div className="icon icon-done" /> 已完成</div>
|
|
|
+ <div className="flow-legend-item"><div className="icon icon-active" /> 进行中/当前</div>
|
|
|
+ <div className="flow-legend-item"><div className="icon icon-pending" /> 未执行</div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div className="page-num">第 2 页 / 共 2 页</div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+}
|