Explorar o código

新增作业详情的归档信息页面和依赖有pdf和打印功能但是打印还没成功

pm hai 3 meses
pai
achega
4fea288517

+ 3 - 0
package.json

@@ -35,6 +35,9 @@
             "clsx": "*",
             "cmdk": "^1.1.1",
             "embla-carousel-react": "^8.6.0",
+            "html-to-image": "^1.11.11",
+            "html2canvas": "^1.4.1",
+            "jspdf": "^2.5.2",
             "i18next": "^25.7.1",
             "i18next-browser-languagedetector": "^8.2.0",
             "input-otp": "^1.4.2",

+ 658 - 0
src/components/WorkJobArchiveReport.css

@@ -0,0 +1,658 @@
+/* 与 test1.html 完全一致的样式 - 作业归档报告 */
+/* 不使用外部字体(内网环境无法访问 Google Fonts),使用系统/本地中文字体栈 */
+.work-job-archive-report {
+  font-family: 'PingFang SC', 'Microsoft YaHei', 'SimHei', 'Helvetica Neue', Helvetica, Arial, sans-serif;
+  -webkit-print-color-adjust: exact;
+  print-color-adjust: exact;
+  background-color: #52525b;
+  padding-top: 2.5rem;
+  padding-bottom: 2.5rem;
+  min-height: 100vh;
+  box-sizing: border-box;
+}
+
+/* 顶部操作栏 - 与 test1 一致 */
+.work-job-archive-report .archive-toolbar {
+  width: 210mm;
+  margin-left: auto;
+  margin-right: auto;
+  margin-bottom: 1.5rem;
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  color: white;
+}
+.work-job-archive-report .archive-toolbar h2 {
+  font-weight: 500;
+}
+.work-job-archive-report .archive-toolbar .print-btn {
+  display: inline-flex;
+  align-items: center;
+  gap: 0.5rem;
+  padding: 0.5rem 1rem;
+  background-color: #2563eb;
+  color: white;
+  border: none;
+  border-radius: 0.25rem;
+  box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1);
+  font-size: 0.875rem;
+  cursor: pointer;
+  transition: background-color 0.2s;
+}
+.work-job-archive-report .archive-toolbar .print-btn:hover {
+  background-color: #3b82f6;
+}
+.work-job-archive-report .archive-toolbar .archive-toolbar-actions {
+  display: flex;
+  align-items: center;
+  gap: 0.75rem;
+}
+.work-job-archive-report .archive-toolbar .export-pdf-btn {
+  display: inline-flex;
+  align-items: center;
+  gap: 0.5rem;
+  padding: 0.5rem 1rem;
+  background-color: transparent;
+  color: white;
+  border: 1px solid rgba(255, 255, 255, 0.7);
+  border-radius: 0.25rem;
+  font-size: 0.875rem;
+  cursor: pointer;
+  transition: background-color 0.2s, border-color 0.2s;
+}
+.work-job-archive-report .archive-toolbar .export-pdf-btn:hover {
+  background-color: rgba(255, 255, 255, 0.15);
+  border-color: white;
+}
+.work-job-archive-report .archive-toolbar .back-btn {
+  display: inline-flex;
+  align-items: center;
+  gap: 0.5rem;
+  padding: 0.5rem 1rem;
+  background-color: transparent;
+  color: white;
+  border: 1px solid rgba(255, 255, 255, 0.7);
+  border-radius: 0.25rem;
+  font-size: 0.875rem;
+  cursor: pointer;
+  transition: background-color 0.2s, border-color 0.2s;
+}
+.work-job-archive-report .archive-toolbar .back-btn:hover {
+  background-color: rgba(255, 255, 255, 0.15);
+  border-color: white;
+}
+
+/* 通用纸张样式 - 与 test1 .page-sheet 完全一致 */
+.work-job-archive-report .page-sheet {
+  background: white;
+  width: 210mm;
+  min-height: 297mm;
+  margin: 0 auto 20px auto;
+  position: relative;
+  box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.3);
+  overflow: hidden;
+  box-sizing: border-box;
+}
+
+/* 打印时分页控制 - 仅打印两页,避免多出空白页 */
+@media print {
+  html, body, #root, #root > * {
+    height: auto !important;
+    min-height: 0 !important;
+    overflow: visible !important;
+  }
+  .work-job-archive-report {
+    background: none !important;
+    margin: 0 !important;
+    padding: 0 !important;
+    height: auto !important;
+    min-height: 0 !important;
+  }
+  .work-job-archive-report .no-print {
+    display: none !important;
+  }
+  .work-job-archive-report .page-sheet {
+    margin: 0 !important;
+    box-shadow: none !important;
+    page-break-after: always;
+    width: 100% !important;
+  }
+  .work-job-archive-report .page-sheet:last-child,
+  .work-job-archive-report .page-sheet.page-2 {
+    page-break-after: auto !important;
+  }
+}
+
+/* 打印时仅保留报告区域,避免根节点占高产生空白页(需在点击打印时给 html/body 加 class print-archive-only) */
+@media print {
+  html.print-archive-only,
+  html.print-archive-only body,
+  html.print-archive-only #root,
+  html.print-archive-only #root > * {
+    height: 0 !important;
+    min-height: 0 !important;
+    max-height: 0 !important;
+    overflow: hidden !important;
+    padding: 0 !important;
+    margin: 0 !important;
+  }
+  html.print-archive-only .work-job-archive-report {
+    position: fixed !important;
+    top: 0 !important;
+    left: 0 !important;
+    right: 0 !important;
+    width: 100% !important;
+    height: auto !important;
+    min-height: auto !important;
+    max-height: none !important;
+    overflow: visible !important;
+    z-index: 99999 !important;
+    background: #fff !important;
+  }
+}
+
+/* 水印 - 与 test1 .watermark-bg 完全一致 */
+.work-job-archive-report .watermark-bg {
+  background-image: url("data:image/svg+xml,%3Csvg width='400' height='400' xmlns='http://www.w3.org/2000/svg'%3E%3Ctext x='50%25' y='50%25' font-size='20' fill='%23f3f4f6' transform='rotate(-45 200 200)' text-anchor='middle'%3E内部归档 INTERNAL ONLY%3C/text%3E%3C/svg%3E");
+}
+
+/* 流程图容器 - 与 test1 .flow-container 完全一致 */
+.work-job-archive-report .flow-container {
+  background-image: radial-gradient(#e5e7eb 1px, transparent 1px);
+  background-size: 20px 20px;
+}
+
+/* 模拟 React Flow 节点 - 与 test1 .rf-node 完全一致 */
+.work-job-archive-report .rf-node {
+  position: absolute;
+  background: white;
+  border: 1px solid #e2e8f0;
+  border-radius: 6px;
+  padding: 8px 12px;
+  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
+  font-size: 12px;
+  width: 120px;
+  text-align: center;
+  z-index: 10;
+  box-sizing: border-box;
+}
+.work-job-archive-report .rf-node.active {
+  border-color: #3b82f6;
+  background-color: #eff6ff;
+  color: #1d4ed8;
+}
+.work-job-archive-report .rf-node.success {
+  border-color: #22c55e;
+  color: #15803d;
+}
+
+/* 模拟连线 - 与 test1 .rf-lines 完全一致 */
+.work-job-archive-report .rf-lines {
+  position: absolute;
+  top: 0;
+  left: 0;
+  width: 100%;
+  height: 100%;
+  pointer-events: none;
+}
+
+/* ========== 第 1 页内容样式(与 test1 一致) ========== */
+.work-job-archive-report .page-sheet.page-1 {
+  padding: 2.5rem;
+  display: flex;
+  flex-direction: column;
+}
+.work-job-archive-report .page-1 .top-bar {
+  position: absolute;
+  top: 0;
+  left: 0;
+  width: 100%;
+  height: 8px;
+  background-color: #1e293b;
+}
+.work-job-archive-report .page-1 .page-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: flex-end;
+  border-bottom: 1px solid #e2e8f0;
+  padding-bottom: 1.5rem;
+  margin-bottom: 2rem;
+}
+.work-job-archive-report .page-1 .page-header h1 {
+  font-size: 1.875rem;
+  font-weight: 700;
+  color: #1e293b;
+  margin: 0;
+}
+.work-job-archive-report .page-1 .page-header .subtitle {
+  font-size: 0.875rem;
+  color: #64748b;
+  margin-top: 0.25rem;
+}
+.work-job-archive-report .page-1 .page-header .doc-no-wrap {
+  text-align: right;
+}
+.work-job-archive-report .page-1 .page-header .doc-no-label {
+  font-size: 0.75rem;
+  color: #94a3b8;
+}
+.work-job-archive-report .page-1 .page-header .doc-no {
+  font-family: ui-monospace, monospace;
+  font-size: 1.125rem;
+  font-weight: 700;
+  color: #334155;
+}
+.work-job-archive-report .page-1 .info-grid {
+  display: grid;
+  grid-template-columns: 1fr 1fr;
+  gap: 2rem;
+  margin-bottom: 2rem;
+}
+.work-job-archive-report .page-1 .info-box {
+  border: 1px solid #e2e8f0;
+  border-radius: 0.25rem;
+  padding: 1.25rem;
+  background-color: rgba(248, 250, 252, 0.5);
+}
+.work-job-archive-report .page-1 .info-box h3 {
+  font-size: 0.75rem;
+  font-weight: 700;
+  color: #94a3b8;
+  text-transform: uppercase;
+  letter-spacing: 0.05em;
+  margin: 0 0 1rem 0;
+}
+.work-job-archive-report .page-1 .info-box .info-row {
+  display: flex;
+  justify-content: space-between;
+  margin-bottom: 0.75rem;
+}
+.work-job-archive-report .page-1 .info-box .info-row:last-child {
+  margin-bottom: 0;
+}
+.work-job-archive-report .page-1 .info-box .info-label {
+  color: #64748b;
+  font-size: 0.875rem;
+}
+.work-job-archive-report .page-1 .info-box .info-value {
+  font-weight: 500;
+  color: #1e293b;
+  font-size: 0.875rem;
+}
+.work-job-archive-report .page-1 .conclusion-status {
+  font-size: 1.875rem;
+  font-weight: 700;
+  color: #16a34a;
+}
+.work-job-archive-report .page-1 .conclusion-badge {
+  padding: 0.25rem 0.5rem;
+  background-color: #dcfce7;
+  color: #15803d;
+  font-size: 0.75rem;
+  border-radius: 0.25rem;
+  border: 1px solid #bbf7d0;
+}
+.work-job-archive-report .page-1 .conclusion-row {
+  display: flex;
+  align-items: center;
+  gap: 1rem;
+  margin-bottom: 0.5rem;
+}
+.work-job-archive-report .page-1 .conclusion-desc {
+  font-size: 0.75rem;
+  color: #64748b;
+  line-height: 1.5;
+  margin: 0;
+}
+.work-job-archive-report .page-1 .section {
+  margin-bottom: 2rem;
+}
+.work-job-archive-report .page-1 .section-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 0.75rem;
+}
+.work-job-archive-report .page-1 .section-title {
+  font-weight: 700;
+  color: #334155;
+  border-left: 4px solid #1e293b;
+  padding-left: 0.75rem;
+  margin: 0;
+}
+.work-job-archive-report .page-1 .section-note {
+  font-size: 0.75rem;
+  color: #94a3b8;
+}
+/* 流程执行摘要 - 横向步骤条(带连线、已完成/进行中/待处理) */
+.work-job-archive-report .page-1 .summary-bar.summary-steps {
+  background: white;
+  border: 1px solid #e2e8f0;
+  border-radius: 0.5rem;
+  padding: 1.5rem 0.75rem;
+}
+.work-job-archive-report .page-1 .summary-bar .summary-grid {
+  display: grid;
+  grid-template-columns: minmax(36px, 1fr) 1fr minmax(36px, 1fr) 1fr minmax(36px, 1fr) 1fr minmax(36px, 1fr) 1fr minmax(36px, 1fr) 1fr minmax(36px, 1fr) 1fr minmax(36px, 1fr);
+  grid-template-rows: auto auto;
+  align-items: center;
+  gap: 0.25rem 0;
+}
+.work-job-archive-report .page-1 .summary-bar .summary-step-icon {
+  width: 36px;
+  height: 36px;
+  border-radius: 50%;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  flex-shrink: 0;
+  color: white;
+  justify-self: center;
+}
+.work-job-archive-report .page-1 .summary-bar .summary-step-icon.summary-step-icon-active {
+  box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.35);
+}
+.work-job-archive-report .page-1 .summary-bar .summary-connector {
+  height: 2px;
+  align-self: center;
+  justify-self: stretch;
+}
+.work-job-archive-report .page-1 .summary-bar .summary-connector-done {
+  background-color: #93c5fd;
+}
+.work-job-archive-report .page-1 .summary-bar .summary-connector-pending {
+  background-color: #e5e7eb;
+}
+.work-job-archive-report .page-1 .summary-bar .summary-step-done {
+  background-color: #22c55e;
+  color: white;
+}
+.work-job-archive-report .page-1 .summary-bar .summary-step-active {
+  background-color: #3b82f6;
+  color: white;
+}
+.work-job-archive-report .page-1 .summary-bar .summary-step-pending {
+  background-color: white;
+  border: 1.5px solid #d1d5db;
+  color: #9ca3af;
+}
+.work-job-archive-report .page-1 .summary-bar .summary-step-pending .summary-step-dot {
+  display: block;
+  width: 6px;
+  height: 6px;
+  border-radius: 50%;
+  background-color: #9ca3af;
+}
+.work-job-archive-report .page-1 .summary-bar .summary-label-cell {
+  grid-row: 2;
+  text-align: center;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  gap: 0.15rem;
+  min-width: 0;
+}
+.work-job-archive-report .page-1 .summary-bar .summary-step-label {
+  font-size: 0.8125rem;
+  font-weight: 500;
+  line-height: 1.2;
+  color: #1f2937;
+  white-space: nowrap;
+}
+.work-job-archive-report .page-1 .summary-bar .summary-step-time {
+  font-size: 0.6875rem;
+  color: #9ca3af;
+}
+.work-job-archive-report .page-1 .summary-bar .summary-step-detail {
+  font-size: 0.6875rem;
+  color: #2563eb;
+}
+.work-job-archive-report .page-1 .summary-footnote {
+  margin: 0.5rem 0 0;
+  font-size: 0.6875rem;
+  color: #94a3b8;
+  text-align: right;
+}
+.work-job-archive-report .page-1 .record-section {
+  flex: 1;
+}
+/* 归档流水日志 - 标题(左侧竖条 + 中文 / 英文) */
+.work-job-archive-report .page-1 .record-log-title {
+  display: flex;
+  align-items: center;
+  gap: 0.5rem;
+  margin-bottom: 0.75rem;
+}
+.work-job-archive-report .page-1 .record-log-title-bar {
+  width: 4px;
+  height: 1.125rem;
+  background-color: #1e293b;
+  border-radius: 2px;
+  flex-shrink: 0;
+}
+.work-job-archive-report .page-1 .record-log-title-text {
+  font-weight: 700;
+  font-size: 1rem;
+  color: #334155;
+  margin: 0;
+}
+.work-job-archive-report .page-1 .record-log-title-en {
+  font-weight: 500;
+  color: #64748b;
+  font-size: 0.875rem;
+}
+/* 归档流水日志 - 表格外层圆角容器(卡片感、浅灰边框) */
+.work-job-archive-report .page-1 .record-table-wrap {
+  background: #fff;
+  border-radius: 8px;
+  overflow: hidden;
+  border: 1px solid #e2e8f0;
+  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
+}
+.work-job-archive-report .page-1 .record-table {
+  width: 100%;
+  text-align: left;
+  font-size: 0.875rem;
+  border-collapse: collapse;
+}
+.work-job-archive-report .page-1 .record-table.record-table-log thead tr {
+  background-color: #f1f5f9;
+  border-bottom: 1px solid #e2e8f0;
+}
+.work-job-archive-report .page-1 .record-table.record-table-log th {
+  padding: 0.625rem 0.875rem;
+  color: #475569;
+  font-weight: 600;
+  font-size: 0.8125rem;
+  vertical-align: middle;
+}
+.work-job-archive-report .page-1 .record-table.record-table-log th.col-no { width: 2.5em; }
+.work-job-archive-report .page-1 .record-table.record-table-log th:nth-child(2) { width: 8em; }
+.work-job-archive-report .page-1 .record-table.record-table-log th:nth-child(3) { width: 9em; }
+.work-job-archive-report .page-1 .record-table.record-table-log th:nth-child(4) { width: 11em; }
+.work-job-archive-report .page-1 .record-table.record-table-log th:nth-child(5) { width: 6em; }
+.work-job-archive-report .page-1 .record-table.record-table-log th:nth-child(6) { min-width: 0; }
+.work-job-archive-report .page-1 .record-table.record-table-log tbody tr {
+  border-bottom: 1px solid #f1f5f9;
+  background: #fff;
+}
+.work-job-archive-report .page-1 .record-table.record-table-log tbody tr:last-child {
+  border-bottom: none;
+}
+.work-job-archive-report .page-1 .record-table.record-table-log td {
+  padding: 0.625rem 0.875rem;
+  vertical-align: middle;
+  color: #334155;
+  font-size: 0.8125rem;
+}
+.work-job-archive-report .page-1 .record-table.record-table-log .col-no {
+  color: #64748b;
+  font-weight: 500;
+}
+.work-job-archive-report .page-1 .record-table.record-table-log .cell-time {
+  font-family: ui-monospace, monospace;
+  font-size: 0.8125rem;
+  color: #475569;
+}
+.work-job-archive-report .page-1 .record-table.record-table-log .cell-operator {
+  display: inline-flex;
+  align-items: center;
+  gap: 0.5rem;
+  color: #334155;
+}
+.work-job-archive-report .page-1 .record-table.record-table-log .archive-log-avatar {
+  width: 24px;
+  height: 24px;
+  flex-shrink: 0;
+}
+.work-job-archive-report .page-1 .record-table.record-table-log .archive-log-avatar [data-slot="avatar-fallback"] {
+  background-color: #e2e8f0;
+  color: #475569;
+  font-size: 0.75rem;
+  font-weight: 600;
+}
+.work-job-archive-report .page-1 .record-table.record-table-log .archive-log-avatar [data-slot="avatar-fallback"].avatar-active {
+  background-color: #dbeafe;
+  color: #1d4ed8;
+}
+.work-job-archive-report .page-1 .record-table.record-table-log .cell-status {
+  display: inline-flex;
+  align-items: center;
+  gap: 0.35rem;
+}
+.work-job-archive-report .page-1 .record-table.record-table-log .cell-status .status-dot {
+  width: 6px;
+  height: 6px;
+  border-radius: 50%;
+  flex-shrink: 0;
+}
+.work-job-archive-report .page-1 .record-table.record-table-log .cell-status.status-done .status-dot {
+  background-color: #22c55e;
+}
+.work-job-archive-report .page-1 .record-table.record-table-log .cell-status.status-processing .status-dot {
+  background-color: #3b82f6;
+}
+.work-job-archive-report .page-1 .record-table.record-table-log .cell-status.status-done {
+  color: #334155;
+}
+.work-job-archive-report .page-1 .record-table.record-table-log .cell-status.status-processing {
+  color: #2563eb;
+}
+.work-job-archive-report .page-1 .page-num {
+  position: absolute;
+  bottom: 1rem;
+  right: 1.5rem;
+  font-size: 0.75rem;
+  color: #94a3b8;
+}
+
+/* ========== 第 2 页内容样式(与 test1 一致) ========== */
+.work-job-archive-report .page-sheet.page-2 {
+  padding: 2rem;
+  display: flex;
+  flex-direction: column;
+}
+.work-job-archive-report .page-2 .page-2-header {
+  border-bottom: 1px solid #cbd5e1;
+  padding-bottom: 0.5rem;
+  margin-bottom: 1rem;
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+}
+.work-job-archive-report .page-2 .page-2-header h2 {
+  font-weight: 700;
+  color: #334155;
+  margin: 0;
+}
+.work-job-archive-report .page-2 .page-2-header .doc-ref {
+  font-size: 0.75rem;
+  color: #94a3b8;
+}
+.work-job-archive-report .page-2 .flow-wrap {
+  flex: 1;
+  border: 1px solid #e2e8f0;
+  border-radius: 0.5rem;
+  position: relative;
+  overflow: hidden;
+  min-height: 400px;
+  width: 100%;
+  box-sizing: border-box;
+}
+.work-job-archive-report .page-2 .archive-flow-print-wrapper {
+  width: 100%;
+  height: 480px;
+  min-height: 480px;
+  min-width: 1px;
+  box-sizing: border-box;
+}
+.work-job-archive-report .page-2 .archive-flow-print-wrapper .archive-flow-inner {
+  width: 100%;
+  height: 480px;
+  min-width: 1px;
+  box-sizing: border-box;
+}
+.work-job-archive-report .page-2 .archive-flow-print-wrapper .archive-flow-inner .react-flow,
+.work-job-archive-report .page-2 .archive-flow-print-wrapper .react-flow {
+  width: 100% !important;
+  height: 100% !important;
+  min-width: 1px;
+  min-height: 400px;
+}
+/* 打印时:完整拓扑图整体缩小并居中,隐藏手柄与缩放控制,缩小比例保证流程完整露出 */
+@media print {
+  .work-job-archive-report .page-2 .flow-wrap {
+    display: flex !important;
+    flex-direction: column !important;
+    align-items: center !important;
+    overflow: visible !important;
+  }
+  .work-job-archive-report .page-2 .archive-flow-print-wrapper {
+    transform: scale(0.62) !important;
+    transform-origin: top center !important;
+  }
+  .work-job-archive-report .page-2 .react-flow__handle,
+  .work-job-archive-report .page-2 .react-flow__controls {
+    display: none !important;
+  }
+  .work-job-archive-report .page-2 .archive-flow-inner,
+  .work-job-archive-report .page-2 .react-flow__viewport {
+    overflow: visible !important;
+  }
+}
+.work-job-archive-report .page-2 .flow-legend {
+  margin-top: 1rem;
+  font-size: 0.75rem;
+  color: #64748b;
+  display: flex;
+  gap: 1rem;
+}
+.work-job-archive-report .page-2 .flow-legend-item {
+  display: flex;
+  align-items: center;
+  gap: 0.25rem;
+}
+.work-job-archive-report .page-2 .flow-legend-item .icon {
+  width: 12px;
+  height: 12px;
+  border-radius: 0.25rem;
+}
+.work-job-archive-report .page-2 .flow-legend-item .icon-done {
+  border: 1px solid #22c55e;
+  background: white;
+}
+.work-job-archive-report .page-2 .flow-legend-item .icon-active {
+  border: 1px solid #3b82f6;
+  background: #eff6ff;
+}
+.work-job-archive-report .page-2 .flow-legend-item .icon-pending {
+  border: 1px solid #cbd5e1;
+  background: white;
+}
+.work-job-archive-report .page-2 .page-num {
+  position: absolute;
+  bottom: 1rem;
+  right: 1.5rem;
+  font-size: 0.75rem;
+  color: #94a3b8;
+}

+ 917 - 0
src/components/WorkJobArchiveReport.tsx

@@ -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>
+  );
+}

+ 32 - 327
src/components/WorkJobDetail.tsx

@@ -14,10 +14,9 @@ import {
   List,
   Plus,
   Minus,
-  Loader2,
-  Archive
+  Loader2
 } from 'lucide-react';
-import { Timeline, Badge, Card, Collapse, Tag, Descriptions } from 'antd';
+import { Timeline, Badge, Card, Collapse, Tag, Descriptions, Button } from 'antd';
 import { CheckCircleOutlined, ClockCircleOutlined, SyncOutlined } from '@ant-design/icons';
 import ReactFlow, {
   Node,
@@ -35,7 +34,6 @@ import ReactFlow, {
 } from 'reactflow';
 import 'reactflow/dist/style.css';
 import { workJobApi, WorkJobVO, WorkflowWorkNodeDO } from '../api/WorkJob';
-import { workHandleApi } from '../api/WorkHandle';
 import { formatDateWithFormat } from '../utils/formatTime';
 import { dictDataApi, DictDataVO } from '../api/DictData';
 import { userApi } from '../api/user';
@@ -56,64 +54,6 @@ interface WorkflowStep {
   hasDetail?: boolean;
 }
 
-// 流转记录数据类型
-interface FlowRecord {
-  id: string;
-  taskNode: string; // 任务节点
-  nodeType?: string; // 节点类型(用于归档列表边框区分)
-  nodeIcon?: string; // 节点图标文件名(与作业流程一致,用于归档列表展示)
-  avatar?: string; // 头像 URL(归档列表优先展示)
-  executor: string; // 任务负责人
-  startTime?: string; // 开始时间
-  endTime?: string; // 结束时间
-  taskStatus: 'completed' | 'in_progress' | 'pending'; // 任务状态
-  executionDescription?: string; // 执行说明
-  duration?: string; // 耗时
-}
-
-// 将归档接口返回项映射为 FlowRecord(getWorkflowWorkLogPage 返回:id, nodeName, type, nodeIcon, nickName, approvalStatus, createTime, taskStartTime, taskFinishTime, taskContent)
-function mapArchiveItemToFlowRecord(item: any, index: number): FlowRecord {
-  const rawStatus = String(item?.taskStatus ?? item?.status ?? item?.approvalStatus ?? '').toLowerCase();
-  let taskStatus: FlowRecord['taskStatus'] = 'pending';
-  if (['completed', 'complete', 'approved', 'done', '1'].some((s) => rawStatus.includes(s))) taskStatus = 'completed';
-  else if (['in_progress', 'running', 'processing', '0'].some((s) => rawStatus.includes(s))) taskStatus = 'in_progress';
-
-  const formatTime = (v: any): string | undefined => {
-    if (v == null || v === '') return undefined;
-    if (typeof v === 'number') return formatDateWithFormat(v);
-    if (v instanceof Date) return formatDateWithFormat(v);
-    if (typeof v === 'string') {
-      const n = Number(v);
-      if (!Number.isNaN(n)) return formatDateWithFormat(n);
-      const d = new Date(v);
-      return Number.isNaN(d.getTime()) ? v : formatDateWithFormat(d);
-    }
-    return undefined;
-  };
-
-  return {
-    id: String(item?.id ?? item?.recordId ?? item?.handleId ?? index),
-    taskNode: String(item?.taskNode ?? item?.nodeName ?? item?.name ?? item?.title ?? '未知节点'),
-    nodeType: item?.nodeType ?? item?.type,
-    nodeIcon: item?.nodeIcon ?? item?.icon,
-    avatar: item?.avatar,
-    executor: String(
-      item?.executor ?? item?.executorName ?? item?.nickName ?? item?.workerName ?? item?.userName ?? item?.operatorName ?? ''
-    ),
-    startTime: formatTime(item?.taskStartTime ?? item?.startTime ?? item?.createTime ?? item?.handleTime),
-    endTime: formatTime(item?.taskFinishTime ?? item?.endTime ?? item?.finishTime ?? item?.completeTime),
-    taskStatus,
-    executionDescription:
-      item?.taskContent ??
-      item?.executionDescription ??
-      item?.approvalOpinion ??
-      item?.description ??
-      item?.remark ??
-      '',
-    duration: item?.duration != null ? String(item.duration) : undefined,
-  };
-}
-
 // 流程节点数据结构
 interface WorkflowNode {
   uuid: string;
@@ -1743,12 +1683,6 @@ export default function WorkJobDetail() {
   const [reactFlowInstance, setReactFlowInstance] = useState<any>(null);
   const [selectedNode, setSelectedNode] = useState<Node | null>(null);
 
-  // 是否展开归档信息面板(展开时左侧作业流程/作业信息/当前任务压缩,右侧显示归档信息列表)
-  const [showArchivePanel, setShowArchivePanel] = useState(false);
-  // 归档信息列表(来自 /isc/workflow-work-log/getWorkflowWorkLogPage,页面初始化时请求)
-  const [archiveList, setArchiveList] = useState<FlowRecord[]>([]);
-  const [archiveLoading, setArchiveLoading] = useState(false);
-
   // 节点信息相关状态
   const [formList, setFormList] = useState<any[]>([]);
   const [drawerUsers, setDrawerUsers] = useState<any[]>([]);
@@ -1851,32 +1785,10 @@ export default function WorkJobDetail() {
     loadData();
   }, []);
 
-  // 获取归档信息列表(workId 传作业的 id 值,在作业详情加载后调用)
-  const loadArchiveList = async () => {
-    if (!jobId) return;
-    try {
-      setArchiveLoading(true);
-      const res = await workHandleApi.getWorkflowWorkLogPage({
-        pageNo: 1,
-        pageSize: -1,
-        workId: Number(jobId),
-      });
-      const data = (res as any)?.data ?? res;
-      const list: any[] = Array.isArray(data) ? data : (data?.list ?? data?.records ?? data?.rows ?? []) ?? [];
-      setArchiveList(list.map((it: any, i: number) => mapArchiveItemToFlowRecord(it, i)));
-    } catch (e: any) {
-      console.error('获取归档信息列表失败:', e);
-      setArchiveList([]);
-    } finally {
-      setArchiveLoading(false);
-    }
-  };
-
   // 获取作业详情
   useEffect(() => {
     getJobStatusDictList();
     if (jobId) {
-      setArchiveList([]); // 切换作业时先清空,再拉取新数据
       loadJobDetail();
     }
   }, [jobId]);
@@ -2338,9 +2250,6 @@ export default function WorkJobDetail() {
       setJobDetail(data);
       console.log('作业详情数据:', data);
 
-      // 用作业 id 作为 workId 拉取归档信息列表
-      loadArchiveList();
-
       // 收集所有的 workerUserId
       const userIds: (number | string)[] = [];
       if (data?.workflowWorkNodeDOList && Array.isArray(data.workflowWorkNodeDOList)) {
@@ -2698,128 +2607,6 @@ export default function WorkJobDetail() {
     }
   };
 
-  // 构建流转记录数据
-  const buildFlowRecords = (): FlowRecord[] => {
-    if (!jobDetail?.workflowWorkNodeDOList || !Array.isArray(jobDetail.workflowWorkNodeDOList)) {
-      return [];
-    }
-
-    // 构建节点映射
-    const nodeMap = new Map<string, WorkflowWorkNodeDO>();
-    jobDetail.workflowWorkNodeDOList.forEach((node: any) => {
-      if (node.uuid) {
-        nodeMap.set(node.uuid, node);
-      }
-    });
-
-    const records: FlowRecord[] = jobDetail.workflowWorkNodeDOList.map((node: any, index: number) => {
-      // 根据字典判断任务状态
-      const taskStatus = getNodeStatusFromDict(node.approvalStatus, approvalStatusDictList, node, nodeMap);
-
-      // 格式化时间
-      const startTime = node.createTime ? formatDateWithFormat(node.createTime) : undefined;
-      
-      // 获取任务负责人(如果有)
-      let executor = '';
-      if (node.workerUserId) {
-        // 从映射中获取用户名,如果没有则使用ID
-        // 尝试多种键类型匹配
-        const userId = node.workerUserId;
-        const userName = userNameMap.get(userId) || 
-                        userNameMap.get(String(userId)) || 
-                        userNameMap.get(Number(userId));
-        executor = userName || String(userId);
-      } else if (node.initiatorName) {
-        executor = node.initiatorName;
-      }
-
-      // 获取描述信息
-      let description = node.approvalOpinion || '';
-      if (!description || description === 'pending') {
-        if (taskStatus === 'completed') {
-          description = '任务已完成';
-        } else if (taskStatus === 'in_progress') {
-          description = '任务正在进行中';
-    } else {
-          description = '等待开始';
-        }
-      }
-
-      return {
-        id: String(node.id || index),
-        taskNode: node.nodeName || '未知节点',
-        nodeType: node.type || 'default',
-        nodeIcon: node.nodeIcon || undefined,
-        executor: executor,
-        startTime: startTime,
-        endTime: undefined,
-        taskStatus: taskStatus,
-        executionDescription: description,
-        duration: undefined,
-      };
-    });
-
-    return records;
-  };
-
-  // 获取流转记录状态文本
-  const getFlowRecordStatusText = (status: FlowRecord['taskStatus']): string => {
-    const statusMap: Record<FlowRecord['taskStatus'], string> = {
-      'completed': '已完成',
-      'in_progress': '执行中',
-      'pending': '待处理',
-    };
-    return statusMap[status] || '未知';
-  };
-
-  // 获取流转记录状态样式
-  const getFlowRecordStatusClassName = (status: FlowRecord['taskStatus']): string => {
-    const statusMap: Record<FlowRecord['taskStatus'], string> = {
-      'completed': 'bg-green-100 text-green-700',
-      'in_progress': 'bg-blue-100 text-blue-700',
-      'pending': 'bg-gray-100 text-gray-700',
-    };
-    return statusMap[status] || 'bg-gray-100 text-gray-700';
-  };
-
-  // 根据节点类型返回归档记录卡片样式(边框 + 类型标签 + 卡片背景色,图标与作业流程一致由下方单独渲染)
-  const getArchiveRecordStyle = (nodeType?: string): { borderClass: string; typeLabel: string; iconBg: string; typeLabelStyle: React.CSSProperties; cardBgStyle: React.CSSProperties } => {
-    const type = (nodeType || 'default').toLowerCase();
-    const base = 'rounded-xl border-l-4 p-4 mb-3 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 };
-    const cardBgStyle: React.CSSProperties = { backgroundColor: c.bg };
-    return { borderClass: `${base} ${c.border}`, typeLabel: c.label, iconBg: c.iconBg, typeLabelStyle, cardBgStyle };
-  };
-
-  // 归档列表:渲染与作业流程一致的节点图标(优先自定义 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) {
     return (
       <div className="min-h-screen bg-gray-50 flex items-center justify-center">
@@ -2871,33 +2658,40 @@ export default function WorkJobDetail() {
               </div>
             </div>
             
-            {/* 返回按钮 */}
-            <button
-              onClick={() => {
-                // 设置菜单信息到 sessionStorage,以便 Dashboard 恢复菜单状态
-                sessionStorage.setItem('navigateToMenu', JSON.stringify({
-                  menu: 'isolationWork',
-                  subMenu: 'workManagement'
-                }));
-                navigate('/dashboard');
-              }}
-              className="flex items-center justify-center w-10 h-10 rounded-lg border border-gray-300 hover:bg-gray-50 hover:border-gray-400 transition-colors flex-shrink-0"
-              title="返回作业管理"
-            >
-              <ArrowLeft className="w-5 h-5 text-gray-600" />
-            </button>
+            <div className="flex items-center gap-2 flex-shrink-0">
+              <Button
+                type="primary"
+                onClick={() => navigate(`/work-job/archive${jobId ? `?id=${jobId}` : ''}`)}
+              >
+                归档信息
+              </Button>
+              <button
+                onClick={() => {
+                  // 设置菜单信息到 sessionStorage,以便 Dashboard 恢复菜单状态
+                  sessionStorage.setItem('navigateToMenu', JSON.stringify({
+                    menu: 'isolationWork',
+                    subMenu: 'workManagement'
+                  }));
+                  navigate('/dashboard');
+                }}
+                className="flex items-center justify-center w-10 h-10 rounded-lg border border-gray-300 hover:bg-gray-50 hover:border-gray-400 transition-colors"
+                title="返回作业管理"
+              >
+                <ArrowLeft className="w-5 h-5 text-gray-600" />
+              </button>
+            </div>
           </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 transition-all" 
+            className="bg-white rounded-xl shadow-sm border border-gray-200 flex flex-col min-h-0" 
             style={{ 
               marginLeft: '20px', 
               maxHeight: '790px', 
-              width: showArchivePanel ? '900px' : '1400px', 
+              width: '1400px', 
               flexShrink: 0 
             }}
           >
@@ -2964,32 +2758,23 @@ export default function WorkJobDetail() {
             </div>
           </div>
 
-          {/* 右侧:作业信息 + 当前任务(展开归档时与归档信息列表并排) */}
+          {/* 右侧:作业信息 + 当前任务 */}
           <div 
             className="flex-1 flex gap-4 min-h-0 flex-shrink-0 overflow-hidden" 
-            style={{ marginRight: '20px', maxHeight: '790px', flexDirection: showArchivePanel ? 'row' : 'column' }}
+            style={{ marginRight: '20px', maxHeight: '790px', flexDirection: '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 }}
+              className="flex flex-col gap-4 min-h-0 overflow-hidden" 
+              style={{ flex: '1 1 auto' }}
             >
             {/* 上部分卡片:作业信息 */}
             <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">
+              <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" />
                   作业信息
                 </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>{`
@@ -3118,86 +2903,6 @@ 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">
-                  {archiveLoading ? (
-                    <div className="flex items-center justify-center py-8 text-gray-500 text-sm">
-                      <Loader2 className="w-4 h-4 mr-2 animate-spin" />
-                      加载归档信息中...
-                    </div>
-                  ) : archiveList.length === 0 ? (
-                    <div className="text-center text-gray-400 py-8 text-sm">暂无执行记录</div>
-                  ) : (
-                    <div className="space-y-1">
-                      {archiveList.map((record) => {
-                        const style = getArchiveRecordStyle(record.nodeType);
-                        return (
-                          <div key={record.id} className={style.borderClass} style={style.cardBgStyle}>
-                            <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 overflow-hidden"
-                                style={record.avatar ? undefined : style.typeLabelStyle}
-                              >
-                                {record.avatar && (
-                                  <img
-                                    src={record.avatar}
-                                    alt={record.executor || record.taskNode}
-                                    className="w-10 h-10 rounded-full object-cover"
-                                    onError={(e) => {
-                                      (e.target as HTMLImageElement).style.display = 'none';
-                                      (e.target as HTMLImageElement).nextElementSibling?.classList.remove('hidden');
-                                    }}
-                                  />
-                                )}
-                                <span className={record.avatar ? 'hidden' : ''}>{renderArchiveNodeIcon(record)}</span>
-                              </div>
-                              <div className="flex-1 min-w-0">
-                                <div className="flex items-center gap-2 flex-wrap">
-                                  <span className="font-semibold text-gray-900">{record.taskNode}</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>

+ 5 - 1
src/main.tsx

@@ -1,5 +1,6 @@
 import { createRoot } from "react-dom/client";
 import { RouterProvider } from "react-router-dom";
+import { Toaster } from "sonner";
 import { router } from "./routes";
 import "./utils/i18n"; // 初始化 i18n
 import { env } from "./utils/env";
@@ -10,5 +11,8 @@ import "antd/dist/reset.css"; // 引入 Ant Design 样式(antd 5.x+ 使用 res
 document.title = env.appTitle;
 
 createRoot(document.getElementById("root")!).render(
-  <RouterProvider router={router} />
+  <>
+    <RouterProvider router={router} />
+    <Toaster position="top-center" richColors />
+  </>
 );

+ 9 - 0
src/routes/index.tsx

@@ -6,6 +6,7 @@ import ProtectedRoute from '../components/ProtectedRoute';
 import ProcessDesigner from '../components/ProcessDesigner';
 import FormDesigner from '../components/FormDesigner';
 import WorkJobDetail from '../components/WorkJobDetail';
+import WorkJobArchiveReport from '../components/WorkJobArchiveReport';
 import LockCabinetDetail from '../components/lockCabinet/LockCabinetDetail';
 
 // 路由配置
@@ -72,6 +73,14 @@ export const router = createBrowserRouter([
       </ProtectedRoute>
     ),
   },
+  {
+    path: '/work-job/archive',
+    element: (
+      <ProtectedRoute>
+        <WorkJobArchiveReport />
+      </ProtectedRoute>
+    ),
+  },
   {
     path: '/clientSystem/*',
     element: (

+ 17 - 0
src/utils/auth.ts

@@ -2,6 +2,7 @@
 
 const LOGIN_FORM_KEY = 'loginForm';
 const TENANT_ID_KEY = 'tenantId';
+const TENANT_NAME_KEY = 'tenantName';
 const TOKEN_KEY = 'token';
 const USER_ID_KEY = 'userId';
 const ACCESS_TOKEN_KEY = 'ACCESS_TOKEN';
@@ -51,6 +52,21 @@ export const removeTenantId = () => {
   localStorage.removeItem(TENANT_ID_KEY);
 };
 
+// 设置租户名称(用于水印等展示)
+export const setTenantName = (tenantName: string) => {
+  localStorage.setItem(TENANT_NAME_KEY, tenantName);
+};
+
+// 获取租户名称
+export const getTenantName = (): string | null => {
+  return localStorage.getItem(TENANT_NAME_KEY);
+};
+
+// 移除租户名称
+export const removeTenantName = () => {
+  localStorage.removeItem(TENANT_NAME_KEY);
+};
+
 // 设置Token(兼容多种格式)
 export const setToken = (tokenData: { accessToken?: string; token?: string } | string) => {
   if (typeof tokenData === 'string') {
@@ -198,6 +214,7 @@ export const removeHmLvt = () => {
 export const clearAuth = () => {
   removeToken();
   removeTenantId();
+  removeTenantName();
   removeUserId();
   removeLoginForm();
   removeAccessToken();

+ 6 - 0
src/views/Login.tsx

@@ -235,6 +235,12 @@ export default function Login() {
         authUtil.setUser(res.user);
       }
 
+      // 存储租户名称(用于归档页水印等展示:用户名@租户名称)
+      const tenantNameToStore = res?.user?.tenantName ?? res?.tenantName ?? loginParams.tenantName ?? (tenantEnable ? tenant?.trim() : '') ?? '';
+      if (tenantNameToStore) {
+        authUtil.setTenantName(tenantNameToStore);
+      }
+
       if (!res) {
         return;
       }