|
@@ -2,8 +2,8 @@ import React, { useState, useEffect, useRef } from 'react';
|
|
|
import { useSearchParams, useNavigate } from 'react-router-dom';
|
|
import { useSearchParams, useNavigate } from 'react-router-dom';
|
|
|
import { toast } from 'sonner';
|
|
import { toast } from 'sonner';
|
|
|
import { ArrowLeft, Check, CheckCircle2, Clock, FileDown, FileText, Flag, Lock, Loader2, Printer, Settings, Shield } from 'lucide-react';
|
|
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, { useNodesState, useEdgesState, Background, BackgroundVariant, Handle, Position, ReactFlowProvider } from 'reactflow';
|
|
|
|
|
+import type { Node as FlowNode, Edge, NodeTypes } from 'reactflow';
|
|
|
import 'reactflow/dist/style.css';
|
|
import 'reactflow/dist/style.css';
|
|
|
import { workJobApi } from '../api/WorkJob';
|
|
import { workJobApi } from '../api/WorkJob';
|
|
|
import { workHandleApi } from '../api/WorkHandle';
|
|
import { workHandleApi } from '../api/WorkHandle';
|
|
@@ -201,6 +201,107 @@ const getNodeStepState = (approvalStatus?: string | number): 'done' | 'active' |
|
|
|
return 'pending';
|
|
return 'pending';
|
|
|
};
|
|
};
|
|
|
|
|
|
|
|
|
|
+/** 流程节点(含 parentUuid,用于主路径计算) */
|
|
|
|
|
+type WorkflowNodeWithParent = {
|
|
|
|
|
+ id?: number;
|
|
|
|
|
+ uuid?: string;
|
|
|
|
|
+ nodeName?: string;
|
|
|
|
|
+ parentUuid?: string;
|
|
|
|
|
+ createTime?: number;
|
|
|
|
|
+ type?: string;
|
|
|
|
|
+ [key: string]: any;
|
|
|
|
|
+};
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * 从完整节点列表中提取「主线」节点(仅一条从开始到结束的路径,并行/分支节点不包含)。
|
|
|
|
|
+ * 与 WorkJobDetail 主路径逻辑一致:找头(createJob/无父)、尾(complete/结束),沿路径遇多子节点时选分支数最少的。
|
|
|
|
|
+ */
|
|
|
|
|
+function getMainPathNodes(
|
|
|
|
|
+ nodeList: WorkflowNodeWithParent[]
|
|
|
|
|
+): WorkflowNodeWithParent[] {
|
|
|
|
|
+ if (!nodeList?.length) return [];
|
|
|
|
|
+ const nodeMap = new Map<string, WorkflowNodeWithParent>();
|
|
|
|
|
+ nodeList.forEach((n) => { if (n.uuid) nodeMap.set(String(n.uuid), n); });
|
|
|
|
|
+
|
|
|
|
|
+ const childrenMap = new Map<string, WorkflowNodeWithParent[]>();
|
|
|
|
|
+ nodeList.forEach((node) => {
|
|
|
|
|
+ if (node.parentUuid) {
|
|
|
|
|
+ const parentUuids = String(node.parentUuid).split(',').map((u) => u.trim()).filter(Boolean);
|
|
|
|
|
+ parentUuids.forEach((parentUuid) => {
|
|
|
|
|
+ if (!childrenMap.has(parentUuid)) childrenMap.set(parentUuid, []);
|
|
|
|
|
+ childrenMap.get(parentUuid)!.push(node);
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+ });
|
|
|
|
|
+ childrenMap.forEach((children) => {
|
|
|
|
|
+ children.sort((a, b) => (a.createTime ?? 0) - (b.createTime ?? 0));
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ const countBranchNodes = (node: WorkflowNodeWithParent, visited: Set<string> = new Set()): number => {
|
|
|
|
|
+ const uid = node.uuid || '';
|
|
|
|
|
+ if (visited.has(uid)) return 0;
|
|
|
|
|
+ visited.add(uid);
|
|
|
|
|
+ const children = childrenMap.get(uid) || [];
|
|
|
|
|
+ let count = children.length;
|
|
|
|
|
+ children.forEach((child) => { count += countBranchNodes(child, visited); });
|
|
|
|
|
+ return count;
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ const findHead = (): WorkflowNodeWithParent | null => {
|
|
|
|
|
+ const byCreateJob = nodeList.find((n) => n.type === 'createJob' || (n.nodeName && (n.nodeName.includes('创建') || n.nodeName.includes('开始'))));
|
|
|
|
|
+ if (byCreateJob) return byCreateJob;
|
|
|
|
|
+ const roots = nodeList.filter((n) => !n.parentUuid || String(n.parentUuid).trim() === '');
|
|
|
|
|
+ if (roots.length > 0) {
|
|
|
|
|
+ roots.sort((a, b) => (a.createTime ?? 0) - (b.createTime ?? 0));
|
|
|
|
|
+ return roots[0];
|
|
|
|
|
+ }
|
|
|
|
|
+ return null;
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ const findTail = (): WorkflowNodeWithParent | null => {
|
|
|
|
|
+ return nodeList.find((n) => n.type === 'complete' || (n.nodeName && (n.nodeName === '完成/结束' || n.nodeName.includes('结束作业')))) ?? null;
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ const buildPathFromHead = (head: WorkflowNodeWithParent, tail: WorkflowNodeWithParent): WorkflowNodeWithParent[] => {
|
|
|
|
|
+ const path: WorkflowNodeWithParent[] = [head];
|
|
|
|
|
+ const visited = new Set<string>([head.uuid || '']);
|
|
|
|
|
+ let current: WorkflowNodeWithParent = head;
|
|
|
|
|
+
|
|
|
|
|
+ while (current && current.uuid !== tail.uuid) {
|
|
|
|
|
+ const children = childrenMap.get(current.uuid || '') || [];
|
|
|
|
|
+ if (children.length === 0) break;
|
|
|
|
|
+ const available = children.filter((c) => !visited.has(c.uuid || ''));
|
|
|
|
|
+ if (available.length === 0) break;
|
|
|
|
|
+
|
|
|
|
|
+ let next: WorkflowNodeWithParent;
|
|
|
|
|
+ if (available.length === 1) {
|
|
|
|
|
+ next = available[0];
|
|
|
|
|
+ } else {
|
|
|
|
|
+ const withCount = available.map((child) => ({
|
|
|
|
|
+ node: child,
|
|
|
|
|
+ count: countBranchNodes(child, new Set()),
|
|
|
|
|
+ }));
|
|
|
|
|
+ withCount.sort((a, b) => a.count - b.count);
|
|
|
|
|
+ next = withCount[0].node;
|
|
|
|
|
+ }
|
|
|
|
|
+ visited.add(next.uuid || '');
|
|
|
|
|
+ path.push(next);
|
|
|
|
|
+ current = next;
|
|
|
|
|
+ }
|
|
|
|
|
+ return path;
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ const head = findHead();
|
|
|
|
|
+ const tail = findTail();
|
|
|
|
|
+ if (!head || !tail) return [];
|
|
|
|
|
+
|
|
|
|
|
+ const mainPath = buildPathFromHead(head, tail);
|
|
|
|
|
+ if (mainPath.length > 0 && mainPath[mainPath.length - 1].uuid !== tail.uuid) {
|
|
|
|
|
+ mainPath.push(tail);
|
|
|
|
|
+ }
|
|
|
|
|
+ return mainPath;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
/** 根据 approvalStatus 得到 React Flow 节点状态 */
|
|
/** 根据 approvalStatus 得到 React Flow 节点状态 */
|
|
|
const getFlowNodeStatus = (approvalStatus?: string | number): 'completed' | 'in_progress' | 'pending' => {
|
|
const getFlowNodeStatus = (approvalStatus?: string | number): 'completed' | 'in_progress' | 'pending' => {
|
|
|
if (approvalStatus == null || approvalStatus === '') return 'pending';
|
|
if (approvalStatus == null || approvalStatus === '') return 'pending';
|
|
@@ -214,7 +315,7 @@ const getFlowNodeStatus = (approvalStatus?: string | number): 'completed' | 'in_
|
|
|
function parseDesignContentLikeJobDetail(
|
|
function parseDesignContentLikeJobDetail(
|
|
|
designContent: string | object,
|
|
designContent: string | object,
|
|
|
workflowWorkNodeDOList: Array<{ uuid?: string; nodeName?: string; approvalStatus?: string | number; [key: string]: any }>
|
|
workflowWorkNodeDOList: Array<{ uuid?: string; nodeName?: string; approvalStatus?: string | number; [key: string]: any }>
|
|
|
-): { nodes: Node[]; edges: Edge[] } | null {
|
|
|
|
|
|
|
+): { nodes: FlowNode[]; edges: Edge[] } | null {
|
|
|
try {
|
|
try {
|
|
|
const jsonData = typeof designContent === 'string' ? JSON.parse(designContent) : designContent;
|
|
const jsonData = typeof designContent === 'string' ? JSON.parse(designContent) : designContent;
|
|
|
if (!jsonData || typeof jsonData !== 'object') return null;
|
|
if (!jsonData || typeof jsonData !== 'object') return null;
|
|
@@ -232,7 +333,7 @@ function parseDesignContentLikeJobDetail(
|
|
|
const nodeMap = new Map<string, { nodeName?: string; approvalStatus?: string | number; type?: string; nodeIcon?: string; id?: number; [key: string]: any }>();
|
|
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); });
|
|
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 validTypes = new Set(['createJob', 'confirm', 'review', 'inputInfo', 'isolation', 'releaseIsolation', 'returnLock', 'complete']);
|
|
|
- const convertedNodes: Node[] = rawNodes
|
|
|
|
|
|
|
+ const convertedNodes: FlowNode[] = rawNodes
|
|
|
.map((node: any) => {
|
|
.map((node: any) => {
|
|
|
const nodeId = node.uuid ?? node.id;
|
|
const nodeId = node.uuid ?? node.id;
|
|
|
if (nodeId == null || nodeId === '') return null;
|
|
if (nodeId == null || nodeId === '') return null;
|
|
@@ -263,7 +364,7 @@ function parseDesignContentLikeJobDetail(
|
|
|
},
|
|
},
|
|
|
};
|
|
};
|
|
|
})
|
|
})
|
|
|
- .filter((n): n is Node => n != null);
|
|
|
|
|
|
|
+ .filter((n): n is FlowNode => n != null);
|
|
|
if (convertedNodes.length === 0) return null;
|
|
if (convertedNodes.length === 0) return null;
|
|
|
|
|
|
|
|
const nodeIdSet = new Set(convertedNodes.map((n) => n.id));
|
|
const nodeIdSet = new Set(convertedNodes.map((n) => n.id));
|
|
@@ -288,13 +389,13 @@ function parseDesignContentLikeJobDetail(
|
|
|
/** 仅根据 workflowWorkNodeDOList 构建简易拓扑(无 designContent 时的兜底) */
|
|
/** 仅根据 workflowWorkNodeDOList 构建简易拓扑(无 designContent 时的兜底) */
|
|
|
function buildFlowFromNodeList(
|
|
function buildFlowFromNodeList(
|
|
|
workflowWorkNodeDOList?: Array<{ uuid?: string; nodeName?: string; approvalStatus?: string | number; parentUuid?: string; childrenUuid?: string; position?: string; createTime?: number; [key: string]: any }>
|
|
workflowWorkNodeDOList?: Array<{ uuid?: string; nodeName?: string; approvalStatus?: string | number; parentUuid?: string; childrenUuid?: string; position?: string; createTime?: number; [key: string]: any }>
|
|
|
-): { nodes: Node[]; edges: Edge[] } {
|
|
|
|
|
|
|
+): { nodes: FlowNode[]; edges: Edge[] } {
|
|
|
const list = workflowWorkNodeDOList ?? [];
|
|
const list = workflowWorkNodeDOList ?? [];
|
|
|
if (list.length === 0) return { nodes: [], edges: [] };
|
|
if (list.length === 0) return { nodes: [], edges: [] };
|
|
|
const sorted = [...list].sort((a, b) => (a.createTime ?? 0) - (b.createTime ?? 0));
|
|
const sorted = [...list].sort((a, b) => (a.createTime ?? 0) - (b.createTime ?? 0));
|
|
|
const gap = 180;
|
|
const gap = 180;
|
|
|
const validTypes = new Set(['createJob', 'confirm', 'review', 'inputInfo', 'isolation', 'releaseIsolation', 'returnLock', 'complete']);
|
|
const validTypes = new Set(['createJob', 'confirm', 'review', 'inputInfo', 'isolation', 'releaseIsolation', 'returnLock', 'complete']);
|
|
|
- const nodes: Node[] = sorted
|
|
|
|
|
|
|
+ const nodes: FlowNode[] = sorted
|
|
|
.filter((n) => n.uuid)
|
|
.filter((n) => n.uuid)
|
|
|
.map((n, i) => {
|
|
.map((n, i) => {
|
|
|
const id = String(n.uuid);
|
|
const id = String(n.uuid);
|
|
@@ -468,7 +569,7 @@ export default function WorkJobArchiveReport() {
|
|
|
]);
|
|
]);
|
|
|
const A4_W = 210;
|
|
const A4_W = 210;
|
|
|
const A4_H = 297;
|
|
const A4_H = 297;
|
|
|
- const scale = 2;
|
|
|
|
|
|
|
+ const scale = 2.5;
|
|
|
/** 将 data URL 转为 Canvas,便于统一用 fitToA4 与 addImage */
|
|
/** 将 data URL 转为 Canvas,便于统一用 fitToA4 与 addImage */
|
|
|
const dataUrlToCanvas = (dataUrl: string): Promise<HTMLCanvasElement> =>
|
|
const dataUrlToCanvas = (dataUrl: string): Promise<HTMLCanvasElement> =>
|
|
|
new Promise((resolve, reject) => {
|
|
new Promise((resolve, reject) => {
|
|
@@ -510,11 +611,52 @@ export default function WorkJobArchiveReport() {
|
|
|
}
|
|
}
|
|
|
return s;
|
|
return s;
|
|
|
};
|
|
};
|
|
|
- /** 将拓扑图内图片转为 data URL(用 canvas 绘制,兼容 blob/同源),确保导出 PDF 时图标能正确渲染;返回恢复函数 */
|
|
|
|
|
|
|
+ /** 通过 fetch 将图片转为 data URL;同源带 cookie,跨域用 CORS */
|
|
|
|
|
+ const fetchImageAsDataUrl = (url: string): Promise<string | null> => {
|
|
|
|
|
+ try {
|
|
|
|
|
+ const isSameOrigin = typeof window !== 'undefined' && new URL(url, window.location.href).origin === window.location.origin;
|
|
|
|
|
+ return fetch(url, { mode: isSameOrigin ? 'same-origin' : 'cors', credentials: isSameOrigin ? 'include' : 'omit' })
|
|
|
|
|
+ .then((r) => (r.ok ? r.blob() : Promise.reject(new Error('not ok'))))
|
|
|
|
|
+ .then(
|
|
|
|
|
+ (blob) =>
|
|
|
|
|
+ new Promise<string | null>((resolve, reject) => {
|
|
|
|
|
+ const r = new FileReader();
|
|
|
|
|
+ r.onload = () => resolve(typeof r.result === 'string' ? r.result : null);
|
|
|
|
|
+ r.onerror = () => reject(r.error);
|
|
|
|
|
+ r.readAsDataURL(blob);
|
|
|
|
|
+ })
|
|
|
|
|
+ )
|
|
|
|
|
+ .catch(() => null);
|
|
|
|
|
+ } catch {
|
|
|
|
|
+ return Promise.resolve(null);
|
|
|
|
|
+ }
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ /** 将容器内图片转为 data URL(导出 PDF 时用);归档流水中的头像不转换,PDF 中只保留文字 */
|
|
|
const preloadFlowImagesToDataUrls = async (container: HTMLElement): Promise<() => void> => {
|
|
const preloadFlowImagesToDataUrls = async (container: HTMLElement): Promise<() => void> => {
|
|
|
const imgs = container.querySelectorAll<HTMLImageElement>('img[src]');
|
|
const imgs = container.querySelectorAll<HTMLImageElement>('img[src]');
|
|
|
const restores: Array<{ el: HTMLImageElement; original: string }> = [];
|
|
const restores: Array<{ el: HTMLImageElement; original: string }> = [];
|
|
|
- const toDataUrl = (img: HTMLImageElement): boolean => {
|
|
|
|
|
|
|
+ const setDataUrl = (img: HTMLImageElement, dataUrl: string, original: string) => {
|
|
|
|
|
+ restores.push({ el: img, original });
|
|
|
|
|
+ img.src = dataUrl;
|
|
|
|
|
+ };
|
|
|
|
|
+ const waitForImgLoad = (img: HTMLImageElement, timeoutMs = 3000): Promise<void> =>
|
|
|
|
|
+ new Promise((resolve) => {
|
|
|
|
|
+ if (img.complete && img.naturalWidth) {
|
|
|
|
|
+ resolve();
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ const t = setTimeout(resolve, timeoutMs);
|
|
|
|
|
+ img.onload = () => {
|
|
|
|
|
+ clearTimeout(t);
|
|
|
|
|
+ resolve();
|
|
|
|
|
+ };
|
|
|
|
|
+ img.onerror = () => {
|
|
|
|
|
+ clearTimeout(t);
|
|
|
|
|
+ resolve();
|
|
|
|
|
+ };
|
|
|
|
|
+ });
|
|
|
|
|
+ const toDataUrlFromCanvas = (img: HTMLImageElement): boolean => {
|
|
|
try {
|
|
try {
|
|
|
if (!img.naturalWidth || !img.naturalHeight) return false;
|
|
if (!img.naturalWidth || !img.naturalHeight) return false;
|
|
|
const c = document.createElement('canvas');
|
|
const c = document.createElement('canvas');
|
|
@@ -524,8 +666,7 @@ export default function WorkJobArchiveReport() {
|
|
|
if (!ctx) return false;
|
|
if (!ctx) return false;
|
|
|
ctx.drawImage(img, 0, 0);
|
|
ctx.drawImage(img, 0, 0);
|
|
|
const dataUrl = c.toDataURL('image/png');
|
|
const dataUrl = c.toDataURL('image/png');
|
|
|
- restores.push({ el: img, original: img.src });
|
|
|
|
|
- img.src = dataUrl;
|
|
|
|
|
|
|
+ setDataUrl(img, dataUrl, img.src);
|
|
|
return true;
|
|
return true;
|
|
|
} catch {
|
|
} catch {
|
|
|
return false;
|
|
return false;
|
|
@@ -533,16 +674,29 @@ export default function WorkJobArchiveReport() {
|
|
|
};
|
|
};
|
|
|
await Promise.all(
|
|
await Promise.all(
|
|
|
Array.from(imgs).map((img) => {
|
|
Array.from(imgs).map((img) => {
|
|
|
- const src = img.getAttribute('src') || img.src;
|
|
|
|
|
|
|
+ if (img.closest('.archive-log-avatar')) return Promise.resolve();
|
|
|
|
|
+ const src = (img.getAttribute('src') || img.src || '').trim();
|
|
|
if (!src || src.startsWith('data:')) return Promise.resolve();
|
|
if (!src || src.startsWith('data:')) return Promise.resolve();
|
|
|
|
|
+ const resolvedUrl = img.src || src;
|
|
|
return new Promise<void>((resolve) => {
|
|
return new Promise<void>((resolve) => {
|
|
|
- if (img.complete && img.naturalWidth) {
|
|
|
|
|
- toDataUrl(img);
|
|
|
|
|
- resolve();
|
|
|
|
|
- return;
|
|
|
|
|
- }
|
|
|
|
|
- img.onload = () => { toDataUrl(img); resolve(); };
|
|
|
|
|
- img.onerror = () => resolve();
|
|
|
|
|
|
|
+ const tryConvert = () => {
|
|
|
|
|
+ if (!img.complete || !img.naturalWidth) {
|
|
|
|
|
+ img.onload = tryConvert;
|
|
|
|
|
+ img.onerror = () => resolve();
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (toDataUrlFromCanvas(img)) {
|
|
|
|
|
+ waitForImgLoad(img).then(resolve);
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ fetchImageAsDataUrl(resolvedUrl).then((dataUrl) => {
|
|
|
|
|
+ if (dataUrl) {
|
|
|
|
|
+ setDataUrl(img, dataUrl, img.src);
|
|
|
|
|
+ waitForImgLoad(img).then(resolve);
|
|
|
|
|
+ } else resolve();
|
|
|
|
|
+ }).catch(() => resolve());
|
|
|
|
|
+ };
|
|
|
|
|
+ tryConvert();
|
|
|
});
|
|
});
|
|
|
})
|
|
})
|
|
|
);
|
|
);
|
|
@@ -592,6 +746,15 @@ export default function WorkJobArchiveReport() {
|
|
|
logging: false,
|
|
logging: false,
|
|
|
onclone: (clonedDoc: Document) => {
|
|
onclone: (clonedDoc: Document) => {
|
|
|
stripOklch(clonedDoc);
|
|
stripOklch(clonedDoc);
|
|
|
|
|
+ // 第一页只固定宽度 210mm,高度随内容(含完整流水日志),导出时再按 A4 高度切片为多页,避免截断
|
|
|
|
|
+ const page1 = clonedDoc.querySelector<HTMLElement>('.work-job-archive-report .page-sheet.page-1');
|
|
|
|
|
+ if (page1) {
|
|
|
|
|
+ page1.style.width = '210mm';
|
|
|
|
|
+ page1.style.minWidth = '210mm';
|
|
|
|
|
+ page1.style.minHeight = '0';
|
|
|
|
|
+ page1.style.overflow = 'visible';
|
|
|
|
|
+ page1.style.boxSizing = 'border-box';
|
|
|
|
|
+ }
|
|
|
// 导出时隐藏连接点与控制按钮,避免拓扑图在 PDF 中渲染混乱
|
|
// 导出时隐藏连接点与控制按钮,避免拓扑图在 PDF 中渲染混乱
|
|
|
const exportStyle = clonedDoc.createElement('style');
|
|
const exportStyle = clonedDoc.createElement('style');
|
|
|
exportStyle.textContent = [
|
|
exportStyle.textContent = [
|
|
@@ -601,8 +764,21 @@ export default function WorkJobArchiveReport() {
|
|
|
/* 避免连接线被裁切,确保 SVG 边线完整导出 */
|
|
/* 避免连接线被裁切,确保 SVG 边线完整导出 */
|
|
|
'.archive-flow-print-wrapper, .archive-flow-inner, .react-flow__viewport, .react-flow__edges, .react-flow__renderer { overflow: visible !important; }',
|
|
'.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; }',
|
|
'.react-flow__edges svg { overflow: visible !important; }',
|
|
|
|
|
+ /* PDF 导出:归档流水操作人列不显示头像,只显示名字文本 */
|
|
|
|
|
+ '.archive-log-avatar { display: none !important; }',
|
|
|
].join('\n');
|
|
].join('\n');
|
|
|
clonedDoc.head.appendChild(exportStyle);
|
|
clonedDoc.head.appendChild(exportStyle);
|
|
|
|
|
+ /* 有头像时 Radix 可能不渲染 Fallback 内容,克隆里补上首字,避免 PDF 中为空白 */
|
|
|
|
|
+ clonedDoc.querySelectorAll('.archive-log-avatar').forEach((avatar) => {
|
|
|
|
|
+ const img = avatar.querySelector('[data-slot="avatar-image"]');
|
|
|
|
|
+ if (!img?.getAttribute('src')) return;
|
|
|
|
|
+ const fallback = avatar.querySelector('[data-slot="avatar-fallback"]');
|
|
|
|
|
+ if (!fallback) return;
|
|
|
|
|
+ const nameNode = avatar.nextSibling;
|
|
|
|
|
+ const nameText = (nameNode?.nodeType === Node.TEXT_NODE ? nameNode.textContent : '')?.trim() ?? '';
|
|
|
|
|
+ const initial = (nameText && nameText.charAt(0)) || (fallback.textContent?.trim()?.charAt(0)) || '?';
|
|
|
|
|
+ fallback.textContent = initial;
|
|
|
|
|
+ });
|
|
|
/* 为边线 SVG 设置宽高,改善 html2canvas 对 SVG 的渲染 */
|
|
/* 为边线 SVG 设置宽高,改善 html2canvas 对 SVG 的渲染 */
|
|
|
clonedDoc.querySelectorAll('.react-flow__edges svg').forEach((svg) => {
|
|
clonedDoc.querySelectorAll('.react-flow__edges svg').forEach((svg) => {
|
|
|
const el = svg as SVGSVGElement;
|
|
const el = svg as SVGSVGElement;
|
|
@@ -614,7 +790,15 @@ export default function WorkJobArchiveReport() {
|
|
|
});
|
|
});
|
|
|
},
|
|
},
|
|
|
};
|
|
};
|
|
|
- const canvas1 = await html2canvas.default(el1, opts);
|
|
|
|
|
|
|
+ // 第一页内头像等图片转为 data URL,避免 PDF 中只渲染出 Fallback 文字(如只显示「李」)
|
|
|
|
|
+ const restorePage1Images = await preloadFlowImagesToDataUrls(el1);
|
|
|
|
|
+ await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
|
|
|
|
|
+ let canvas1: HTMLCanvasElement;
|
|
|
|
|
+ try {
|
|
|
|
|
+ canvas1 = await html2canvas.default(el1, opts);
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ restorePage1Images();
|
|
|
|
|
+ }
|
|
|
|
|
|
|
|
// 截取第二页前:统一把拓扑图内图片转为 data URL(两种导出方式都生效),并隐藏手柄/控制栏(不改 overflow,避免 PDF 中位置偏移)
|
|
// 截取第二页前:统一把拓扑图内图片转为 data URL(两种导出方式都生效),并隐藏手柄/控制栏(不改 overflow,避免 PDF 中位置偏移)
|
|
|
const restoreFlowImages = await preloadFlowImagesToDataUrls(el2);
|
|
const restoreFlowImages = await preloadFlowImagesToDataUrls(el2);
|
|
@@ -637,20 +821,55 @@ export default function WorkJobArchiveReport() {
|
|
|
document.getElementById('pdf-export-flow-style')?.remove();
|
|
document.getElementById('pdf-export-flow-style')?.remove();
|
|
|
}
|
|
}
|
|
|
const pdf = new jsPDF({ orientation: 'portrait', unit: 'mm', format: 'a4' });
|
|
const pdf = new jsPDF({ orientation: 'portrait', unit: 'mm', format: 'a4' });
|
|
|
|
|
+ // 第一页内容可能超过一屏:按 A4 高度切片为多页,保证流水日志等全部展示且每页 1:1 A4
|
|
|
|
|
+ const sliceHeightPx = canvas1.width * (A4_H / A4_W);
|
|
|
|
|
+ const needSlice = canvas1.height > sliceHeightPx;
|
|
|
|
|
+ if (!needSlice) {
|
|
|
|
|
+ const r1 = canvas1.width / canvas1.height;
|
|
|
|
|
+ const isA4Ratio = Math.abs(r1 - A4_W / A4_H) < 0.05;
|
|
|
|
|
+ if (isA4Ratio) {
|
|
|
|
|
+ pdf.addImage(canvas1.toDataURL('image/jpeg', 0.92), 'JPEG', 0, 0, A4_W, A4_H);
|
|
|
|
|
+ } else {
|
|
|
|
|
+ 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);
|
|
|
|
|
+ const x1 = (A4_W - size1.w) / 2;
|
|
|
|
|
+ const y1 = (A4_H - size1.h) / 2;
|
|
|
|
|
+ pdf.addImage(canvas1.toDataURL('image/jpeg', 0.92), 'JPEG', x1, y1, size1.w, size1.h);
|
|
|
|
|
+ }
|
|
|
|
|
+ } else {
|
|
|
|
|
+ for (let y = 0; y < canvas1.height; y += sliceHeightPx) {
|
|
|
|
|
+ const sh = Math.min(sliceHeightPx, canvas1.height - y);
|
|
|
|
|
+ const temp = document.createElement('canvas');
|
|
|
|
|
+ temp.width = canvas1.width;
|
|
|
|
|
+ temp.height = sliceHeightPx;
|
|
|
|
|
+ const ctx = temp.getContext('2d');
|
|
|
|
|
+ if (ctx) {
|
|
|
|
|
+ ctx.fillStyle = '#ffffff';
|
|
|
|
|
+ ctx.fillRect(0, 0, temp.width, temp.height);
|
|
|
|
|
+ ctx.drawImage(canvas1, 0, y, canvas1.width, sh, 0, 0, canvas1.width, sh);
|
|
|
|
|
+ }
|
|
|
|
|
+ pdf.addImage(temp.toDataURL('image/jpeg', 0.92), 'JPEG', 0, 0, A4_W, A4_H);
|
|
|
|
|
+ if (y + sliceHeightPx < canvas1.height) pdf.addPage();
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
const fitToA4 = (canvas: HTMLCanvasElement) => {
|
|
const fitToA4 = (canvas: HTMLCanvasElement) => {
|
|
|
const w = canvas.width;
|
|
const w = canvas.width;
|
|
|
const h = canvas.height;
|
|
const h = canvas.height;
|
|
|
const r = w / h;
|
|
const r = w / h;
|
|
|
- if (A4_W / A4_H >= r) {
|
|
|
|
|
- return { w: A4_H * r, h: A4_H };
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ if (A4_W / A4_H >= r) return { w: A4_H * r, h: A4_H };
|
|
|
return { w: A4_W, h: A4_W / r };
|
|
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);
|
|
const size2 = fitToA4(canvas2);
|
|
|
|
|
+ const x2 = (A4_W - size2.w) / 2;
|
|
|
|
|
+ const y2 = (A4_H - size2.h) / 2;
|
|
|
pdf.addPage();
|
|
pdf.addPage();
|
|
|
- pdf.addImage(canvas2.toDataURL('image/jpeg', 0.92), 'JPEG', 0, 0, size2.w, size2.h);
|
|
|
|
|
|
|
+ pdf.addImage(canvas2.toDataURL('image/jpeg', 0.92), 'JPEG', x2, y2, size2.w, size2.h);
|
|
|
const fileName = `作业归档报告_${jobDetail?.orderNo ?? jobDetail?.code ?? jobId ?? 'export'}.pdf`;
|
|
const fileName = `作业归档报告_${jobDetail?.orderNo ?? jobDetail?.code ?? jobId ?? 'export'}.pdf`;
|
|
|
pdf.save(fileName);
|
|
pdf.save(fileName);
|
|
|
toast.success('PDF 导出成功', { id: loadingId });
|
|
toast.success('PDF 导出成功', { id: loadingId });
|
|
@@ -670,7 +889,9 @@ export default function WorkJobArchiveReport() {
|
|
|
|
|
|
|
|
const conclusion = jobDetail ? statusToConclusion(jobDetail.status) : statusToConclusion('');
|
|
const conclusion = jobDetail ? statusToConclusion(jobDetail.status) : statusToConclusion('');
|
|
|
const nodeList = jobDetail?.workflowWorkNodeDOList ?? [];
|
|
const nodeList = jobDetail?.workflowWorkNodeDOList ?? [];
|
|
|
- const sortedNodes = [...nodeList].sort((a, b) => (a.createTime ?? 0) - (b.createTime ?? 0));
|
|
|
|
|
|
|
+ // 流程执行摘要只渲染主线节点,并行/分支节点不展示(与 WorkJobDetail 主路径逻辑一致)
|
|
|
|
|
+ const mainPathNodes = getMainPathNodes(nodeList as WorkflowNodeWithParent[]);
|
|
|
|
|
+ const sortedNodes = mainPathNodes.length > 0 ? mainPathNodes : [...nodeList].sort((a, b) => (a.createTime ?? 0) - (b.createTime ?? 0));
|
|
|
const currentNodeId = jobDetail?.currentNodeId ?? null;
|
|
const currentNodeId = jobDetail?.currentNodeId ?? null;
|
|
|
|
|
|
|
|
if (loading) {
|
|
if (loading) {
|
|
@@ -727,6 +948,8 @@ export default function WorkJobArchiveReport() {
|
|
|
</div>
|
|
</div>
|
|
|
</div>
|
|
</div>
|
|
|
|
|
|
|
|
|
|
+ {/* 纸张容器:大屏下按视口缩放,1:1 还原 A4 观感、减小左右留白 */}
|
|
|
|
|
+ <div className="archive-pages-wrap">
|
|
|
{/* ================= 第 1 页:基本信息与结果(与 test1 一致) ================= */}
|
|
{/* ================= 第 1 页:基本信息与结果(与 test1 一致) ================= */}
|
|
|
<div ref={page1Ref} className="page-sheet watermark-bg page-1" style={watermarkStyle}>
|
|
<div ref={page1Ref} className="page-sheet watermark-bg page-1" style={watermarkStyle}>
|
|
|
<div className="top-bar" />
|
|
<div className="top-bar" />
|
|
@@ -736,7 +959,7 @@ export default function WorkJobArchiveReport() {
|
|
|
<p className="subtitle">Archiving Report for Complex Workflow</p>
|
|
<p className="subtitle">Archiving Report for Complex Workflow</p>
|
|
|
</div>
|
|
</div>
|
|
|
<div className="doc-no-wrap">
|
|
<div className="doc-no-wrap">
|
|
|
- <div className="doc-no-label">单据编号</div>
|
|
|
|
|
|
|
+ <div className="doc-no-label">作业单据编号</div>
|
|
|
<div className="doc-no">{jobDetail?.orderNo ?? jobDetail?.code ?? '-'}</div>
|
|
<div className="doc-no">{jobDetail?.orderNo ?? jobDetail?.code ?? '-'}</div>
|
|
|
</div>
|
|
</div>
|
|
|
</header>
|
|
</header>
|
|
@@ -763,35 +986,39 @@ export default function WorkJobArchiveReport() {
|
|
|
<h3 className="section-title">流程执行摘要</h3>
|
|
<h3 className="section-title">流程执行摘要</h3>
|
|
|
</div>
|
|
</div>
|
|
|
<div className="summary-bar summary-steps">
|
|
<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'}`} />);
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ {(() => {
|
|
|
|
|
+ const nodes = sortedNodes;
|
|
|
|
|
+ const stepCount = nodes.length;
|
|
|
|
|
+ if (stepCount === 0) return null;
|
|
|
|
|
+ const gridCols = Array.from({ length: stepCount * 2 - 1 }, (_, j) => (j % 2 === 0 ? 'minmax(36px, 1fr)' : '1fr')).join(' ');
|
|
|
|
|
+ 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 < stepCount; 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 < stepCount - 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>
|
|
|
|
|
|
|
+ }
|
|
|
|
|
+ for (let i = 0; i < stepCount; 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 (
|
|
|
|
|
+ <div className="summary-grid" style={{ gridTemplateColumns: gridCols }}>
|
|
|
|
|
+ {el}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ );
|
|
|
|
|
+ })()}
|
|
|
</div>
|
|
</div>
|
|
|
<p className="summary-footnote">*流程分支详情请翻阅附录页完整拓扑图</p>
|
|
<p className="summary-footnote">*流程分支详情请翻阅附录页完整拓扑图</p>
|
|
|
</section>
|
|
</section>
|
|
@@ -834,8 +1061,15 @@ export default function WorkJobArchiveReport() {
|
|
|
<td>
|
|
<td>
|
|
|
<span className="cell-operator">
|
|
<span className="cell-operator">
|
|
|
<Avatar className="archive-log-avatar">
|
|
<Avatar className="archive-log-avatar">
|
|
|
- {(log as any).avatar && <AvatarImage src={(log as any).avatar} alt={operator} />}
|
|
|
|
|
- <AvatarFallback className={isProcessing ? 'avatar-active' : ''}>{initial}</AvatarFallback>
|
|
|
|
|
|
|
+ {(log as any).avatar ? (
|
|
|
|
|
+ <AvatarImage
|
|
|
|
|
+ src={(log as any).avatar}
|
|
|
|
|
+ alt={operator}
|
|
|
|
|
+ />
|
|
|
|
|
+ ) : null}
|
|
|
|
|
+ <AvatarFallback className={isProcessing ? 'avatar-active' : ''}>
|
|
|
|
|
+ {initial}
|
|
|
|
|
+ </AvatarFallback>
|
|
|
</Avatar>
|
|
</Avatar>
|
|
|
{operator}
|
|
{operator}
|
|
|
</span>
|
|
</span>
|
|
@@ -906,6 +1140,7 @@ export default function WorkJobArchiveReport() {
|
|
|
|
|
|
|
|
<div className="page-num">{t('common.pageOfTotal', { current: 2, total: 2 })}</div>
|
|
<div className="page-num">{t('common.pageOfTotal', { current: 2, total: 2 })}</div>
|
|
|
</div>
|
|
</div>
|
|
|
|
|
+ </div>
|
|
|
</div>
|
|
</div>
|
|
|
);
|
|
);
|
|
|
}
|
|
}
|