Преглед на файлове

feat: 行政审批打印功能

liujt преди 1 седмица
родител
ревизия
a28edeaf5c
променени са 2 файла, в които са добавени 445 реда и са изтрити 2 реда
  1. 67 0
      src/BIZComponents/processSubmitDialog/components/blank_table_headers.json
  2. 378 2
      src/views/bpm/collaborative/detail.vue

+ 67 - 0
src/BIZComponents/processSubmitDialog/components/blank_table_headers.json

@@ -0,0 +1,67 @@
+{
+  "blank_business_trip": [
+    {"prop": "transportation", "label": "交通工具"},
+    {"prop": "isRoundTrip", "label": "单程往返"},
+    {"prop": "departureCity", "label": "出发城市"},
+    {"prop": "destinationCity", "label": "目的城市"},
+    {"prop": "startTime", "label": "开始时间"},
+    {"prop": "endTime", "label": "结束时间"},
+    {"prop": "duration", "label": "时长"}
+  ],
+  "blank_business_component": [
+    {"prop": "startDate", "label": "出发日期"},
+    {"prop": "endDate", "label": "到达日期"},
+    {"prop": "startPlace", "label": "出发地"},
+    {"prop": "endPlace", "label": "到达地"},
+    {"prop": "transportationWay", "label": "交通方式"},
+    {"prop": "price", "label": "交通费用"}
+  ],
+  "blank_application_component": [
+    {"prop": "productName", "label": "产品名称"},
+    {"prop": "quantity", "label": "数量"},
+    {"prop": "specification", "label": "规格型号"}
+  ],
+  "blank_product_specification": [
+    {"prop": "specification", "label": "产品规格型号"},
+    {"prop": "num", "label": "数量"},
+    {"prop": "batchNo", "label": "批号"}
+  ],
+  "blank_purchase_requisition": [
+    {"prop": "materialName", "label": "物料名称"},
+    {"prop": "specification", "label": "规格型号"},
+    {"prop": "unit", "label": "单位"},
+    {"prop": "quantity", "label": "数量"},
+    {"prop": "purpose", "label": "用途"},
+    {"prop": "expectedDeliveryDate", "label": "期望交付日期"},
+    {"prop": "remark", "label": "备注"}
+  ]
+  ,
+  "blank_use_seal": [
+    {"prop": "useSealType", "label": "印章使用类型"},
+    {"prop": "useSealRemark", "label": "印章使用事由"},
+    {"prop": "useSealName", "label": "印章名称"}
+  ],
+  "blank_use_qualification": [
+    {"prop": "useQualificationType", "label": "资质类型"},
+    {"prop": "useQualificationName", "label": "使用资质名称"},
+    {"prop": "useQualificationRemark", "label": "资质使用事由"}
+  ],
+  "blank_event_component": [
+    {"prop": "eventTime", "label": "事件发生时间"},
+    {"prop": "eventHospital", "label": "事件发生的医院及科室"},
+    {"prop": "eventDesc", "label": "事件详细描述"},
+    {"prop": "eventImage", "label": "图片上传"},
+    {"prop": "eventOpinion", "label": "其他意见"}
+  ],
+  "blank_reissue_component": [
+    {"prop": "reissueType", "label": "补发型号规格"},
+    {"prop": "reissueOtherType", "label": "补发其他规格型号"},
+    {"prop": "reissueContact", "label": "收件地址/联系人/电话"},
+    {"prop": "reissueOtherReq", "label": "其他要求"}
+  ],
+  "blank_adopzrdd": [
+    {"prop": "remark", "label": "报销事项"},
+    {"prop": "price", "label": "金额"},
+    {"prop": "file", "label": "附件"}
+  ]
+}

+ 378 - 2
src/views/bpm/collaborative/detail.vue

@@ -1,4 +1,5 @@
 <template>
+  <div>
   <ele-modal
     :visible="formDetailDialogFlag"
     :width="modelWidth"
@@ -26,7 +27,7 @@
       </div>
     </template>
     <div style="display: flex; height: 100%; justify-content: space-between">
-      <div class="form-box" :style="fmStyle">
+      <div id="printSection" class="form-box" :style="fmStyle">
         <fm-generate-form
           v-if="Object.keys(form?.formJson || {}).length !== 0"
           :data="jsonData"
@@ -206,9 +207,21 @@
       :isInterior="true"
     ></outgoingDetails>
     <div slot="footer" class="footer">
+      <el-button type="primary" @click="print">打印</el-button>
+      <!-- <el-button @click="openPreview">预览</el-button> -->
       <el-button @click="cancel">返回</el-button>
     </div>
   </ele-modal>
+
+  <!-- 打印 HTML 预览弹窗 -->
+  <el-dialog :visible.sync="previewDialog" width="80%" top="20px" title="打印预览">
+    <div style="max-height:70vh; overflow:auto; background:#fff; padding:16px;" v-html="previewHtml"></div>
+    <span slot="footer" class="dialog-footer">
+      <el-button @click="previewDialog = false">关闭</el-button>
+      <el-button type="primary" @click="printPreview">打印预览</el-button>
+    </span>
+  </el-dialog>
+  </div>
 </template>
 <script>
 import dictMixins from '@/mixins/dictMixins';
@@ -227,6 +240,8 @@ import Detail from '@/views/bpm/processInstance/detailNew.vue';
 import applicationComponent from '@/BIZComponents/processSubmitDialog/components/applicationComponent.vue';
 import purchaseRequisitionComponent from '@/BIZComponents/processSubmitDialog/components/purchaseRequisitionComponent.vue';
 import businessTripComponent from '@/BIZComponents/processSubmitDialog/components/businessTripComponent.vue';
+import { getGroupUserTree, getProduceTreeByCode } from '@/api/main';
+import { listOrganizations, getUserPage } from '@/api/system/organization';
 
 export default {
   name: 'formDetailDialog',
@@ -259,7 +274,10 @@ export default {
       form: {},
       jsonData: {},
       outgoingData: {},
-      isRight: false
+      isRight: false,
+      previewDialog: false,
+      previewHtml: '',
+      remoteOptionsCache: {}
     };
   },
   computed: {
@@ -303,6 +321,364 @@ export default {
           };
           // item.url = item.url && item.url.replace('/api', this.APIUrl);
         });
+      // 预加载远程数据(例如 getGroupUserTree)以便打印时将 id 转为名称
+      this.ensureRemoteData(this.jsonData).catch((e) => {
+        console.warn('预加载远程数据失败', e);
+      });
+    },
+
+    async ensureRemoteData(makingJson) {
+      if (!makingJson || !makingJson.list) return;
+      const tasks = [];
+      makingJson.list.forEach((item) => {
+        const remoteKey = item.options && item.options.remoteDataSource;
+        if (remoteKey && !this.remoteOptionsCache[remoteKey]) {
+          // 目前只实现 getGroupUserTree 的预加载
+          if (remoteKey === 'getGroupUserTree') {
+            tasks.push(
+              getGroupUserTree()
+                .then((res) => {
+                  // res 可能是树形,扁平化为 id->node 映射
+                  const map = {};
+                  const walk = (nodes) => {
+                    if (!nodes) return;
+                    nodes.forEach((n) => {
+                      if (n.id !== undefined) map[n.id] = n;
+                      if (n.children && n.children.length) walk(n.children);
+                    });
+                  };
+                  walk(res || []);
+                  this.remoteOptionsCache[remoteKey] = map;
+                })
+                .catch((e) => {
+                  console.warn('加载远程数据失败', remoteKey, e);
+                })
+            );
+          } else if (remoteKey === 'getUserPage') {
+            tasks.push(
+              getUserPage({ pageNum: 1, pageSize: -1 })
+                .then((res) => {
+                  const map = {};
+                  const list = res?.list || [];
+                  list.forEach((user) => {
+                    if (user && user.id !== undefined) {
+                      map[user.id] = user;
+                    }
+                  });
+                  this.remoteOptionsCache[remoteKey] = map;
+                })
+                .catch((e) => {
+                  console.warn('加载远程数据失败', remoteKey, e);
+                })
+            );
+          } else if (remoteKey === 'getDeptTree') {
+            tasks.push(
+              listOrganizations()
+                .then((res) => {
+                  const map = {};
+                  const list = Array.isArray(res) ? res : [];
+                  list.forEach((dept) => {
+                    if (dept && dept.id !== undefined) {
+                      map[dept.id] = dept;
+                    }
+                  });
+                  this.remoteOptionsCache[remoteKey] = map;
+                })
+                .catch((e) => {
+                  console.warn('加载远程数据失败', remoteKey, e);
+                })
+            );
+          }
+        }
+      });
+      await Promise.all(tasks);
+    },
+    print1() {
+      const printSection = document.getElementById('printSection');
+      // 创建打印任务
+      const printWindow = window.open('', '_blank');
+      printWindow.document.open();
+      printWindow.document.write('<html><head><title>打印预览</title>');
+      printWindow.document.write(
+        '<link rel="stylesheet" href="your-stylesheet-url.css" type="text/css" />'
+      );
+      printWindow.document.write('</head><body>');
+      printWindow.document.write(printSection.innerHTML);
+      printWindow.document.write('</body></html>');
+      printWindow.document.close();
+      printWindow.onload = function () {
+        printWindow.print();
+      };
+    },
+    async print() {
+      // 将 formJson.makingJson 与 valueJson 合并生成 HTML,再打开新窗口打印
+      try {
+        const making = this.jsonData || (this.form.formJson && JSON.parse(this.form.formJson.makingJson));
+        // 确保远程数据加载完成(用于 id->name 映射)
+        await this.ensureRemoteData(making);
+        const values = this.form.valueJson || {};
+        const html = this.generatePrintHtml(making, values, this.form);
+        const w = window.open('', '_blank');
+        if (!w) {
+          this.$message && this.$message.error && this.$message.error('弹窗被拦截,无法打开预览窗口');
+          return;
+        }
+        w.document.open();
+        w.document.write(html);
+        w.document.close();
+        w.focus();
+        setTimeout(() => {
+          w.print();
+        }, 300);
+      } catch (e) {
+        console.error('打印失败', e);
+        console.log('打印 JSON 数据:', this.jsonData, this.form.valueJson);
+      }
+    },
+
+    async openPreview() {
+      try {
+        const making = this.jsonData || (this.form.formJson && JSON.parse(this.form.formJson.makingJson));
+        // 确保远程数据加载完成(用于 id->name 映射)
+        await this.ensureRemoteData(making);
+        const values = this.form.valueJson || {};
+        const full = this.generatePrintHtml(making, values, this.form);
+        // 提取 <style> 和 .pf-container,用相同样式渲染预览
+        const styleMatch = full.match(/<style>([\s\S]*?)<\/style>/);
+        const containerMatch = full.match(/<div class="pf-container">[\s\S]*<\/div>/);
+        const styleTag = styleMatch ? `<style>${styleMatch[1]}</style>` : '';
+        this.previewHtml = styleTag + (containerMatch ? containerMatch[0] : full);
+        this.previewDialog = true;
+      } catch (e) {
+        console.error('生成预览失败', e);
+        this.$message && this.$message.error && this.$message.error('生成预览失败');
+      }
+    },
+
+    async printPreview() {
+      try {
+        const making = this.jsonData || (this.form.formJson && JSON.parse(this.form.formJson.makingJson));
+        await this.ensureRemoteData(making);
+        const values = this.form.valueJson || {};
+        const full = this.generatePrintHtml(making, values, this.form);
+        const w = window.open('', '_blank');
+        if (!w) {
+          this.$message && this.$message.error && this.$message.error('弹窗被拦截,无法打开预览窗口');
+          return;
+        }
+        w.document.open();
+        w.document.write(full);
+        w.document.close();
+        w.focus();
+        setTimeout(() => w.print(), 300);
+      } catch (e) {
+        console.error('打印预览失败', e);
+        this.$message && this.$message.error && this.$message.error('打印预览失败');
+      }
+    },
+
+    generatePrintHtml(makingJson, valueJson, formMeta = {}) {
+      // 加载自定义区域表头映射(如果存在)
+      let headerMap = {};
+      try {
+        // eslint-disable-next-line no-undef
+        headerMap = require('@/BIZComponents/processSubmitDialog/components/blank_table_headers.json');
+      } catch (e) {
+        headerMap = {};
+      }
+
+      const title = this.escapeHtml(formMeta.name || formMeta.formName || '表单打印');
+      const list = (makingJson && makingJson.list) || [];
+
+      const rows = [];
+      const isBlankField = (item) => item.type === 'blank' || (item.model && String(item.model).indexOf('blank_') === 0);
+      const renderBlankTable = (item, rawRows = []) => {
+        const mapKey = item.model || item.key || '';
+        let cols = [];
+        if (headerMap && headerMap[mapKey]) {
+          cols = headerMap[mapKey].map(c => c.prop);
+        } else {
+          const set = new Set();
+          rawRows.forEach((r) => {
+            if (r && typeof r === 'object') {
+              Object.keys(r).forEach(k => set.add(k));
+            }
+          });
+          cols = Array.from(set);
+        }
+
+        const ths = cols.map(c => {
+          let headLabel = c;
+          if (headerMap && headerMap[mapKey]) {
+            const found = headerMap[mapKey].find(h => h.prop === c);
+            if (found && found.label) headLabel = found.label;
+          }
+          return '<th>' + this.escapeHtml(headLabel) + '</th>';
+        }).join('');
+
+        const trs = rawRows.map(r => {
+          const tds = cols.map(c => {
+            const v = (r && r[c] !== undefined && r[c] !== null) ? r[c] : '';
+            if (typeof v === 'object') return '<td>' + this.escapeHtml(JSON.stringify(v)) + '</td>';
+            return '<td>' + this.escapeHtml(String(v)) + '</td>';
+          }).join('');
+          return '<tr>' + tds + '</tr>';
+        }).join('');
+
+        return '<table class="pf-table"><thead><tr>' + ths + '</tr></thead><tbody>' + trs + '</tbody></table>';
+      };
+
+      for (let i = 0; i < list.length; i++) {
+        const item = list[i];
+        // 标题优先取 name/label,若不存在则回退到 model 的可读化,最终回退到 ''
+        const rawLabel = item.name || item.label || (item.model ? String(item.model).replace(/^blank_/, '').replace(/_/g, ' ') : '') || '';
+        const label = this.escapeHtml(rawLabel);
+        const key = item.model || item.key || '';
+        let val = '';
+        try {
+          const raw = valueJson && (valueJson[key] !== undefined ? valueJson[key] : valueJson[item.model]);
+          if (raw === null || raw === undefined || raw === '') {
+            if (isBlankField(item)) {
+              val = renderBlankTable(item, []);
+            } else {
+              val = '';
+            }
+          } else if (typeof raw === 'string' || typeof raw === 'number') {
+            const remoteKey = item.options && item.options.remoteDataSource;
+            if (remoteKey && this.remoteOptionsCache[remoteKey]) {
+              const map = this.remoteOptionsCache[remoteKey];
+              val = map && map[raw] ? (map[raw].name || map[raw]) : String(raw);
+            } else {
+              val = this.escapeHtml(String(raw));
+            }
+          } else if (Array.isArray(raw)) {
+            if (raw.length === 0) {
+              if (isBlankField(item)) {
+                val = renderBlankTable(item, []);
+              } else {
+                val = '';
+              }
+            } else {
+              const first = raw[0];
+              // 先检测是否为附件数组(对象形式或 id 列表)
+              const isAttachmentObjectArray = raw.every(r => r && (r.id !== undefined || r.fileId !== undefined || r.uid !== undefined));
+              const looksLikeAttachmentByMeta = (item.type && String(item.type).toLowerCase().includes('upload')) || (item.model && String(item.model).toLowerCase().includes('file')) || (label && label.includes('附件'));
+              if (isAttachmentObjectArray) {
+                val = raw.length + ' 个附件';
+              } else if ((typeof first === 'string' || typeof first === 'number') && looksLikeAttachmentByMeta) {
+                // 原始为 id 列表,并且通过字段/label 推断为附件,显示数量
+                val = raw.length + ' 个附件';
+              } else if ((typeof first === 'string' || typeof first === 'number')) {
+                const remoteKey = item.options && item.options.remoteDataSource;
+                if (remoteKey && this.remoteOptionsCache[remoteKey]) {
+                  const map = this.remoteOptionsCache[remoteKey];
+                  val = raw.map(id => (map[id] ? (map[id].name || map[id]) : id)).join(', ');
+                } else {
+                  val = raw.map(r => this.escapeHtml(String(r))).join(', ');
+                }
+              } else if (item.model === 'manage_workorder' && typeof first === 'object' && first !== null) {
+                val = raw
+                  .map(r => (r && r.code !== undefined ? this.escapeHtml(String(r.code)) : this.escapeHtml(JSON.stringify(r))))
+                  .join(', ');
+              } else if (typeof first === 'object' && first !== null && (item.type === 'blank' || (item.model && item.model.indexOf('blank_') === 0))) {
+                // 自定义区域渲染为表格
+                const mapKey = item.model || item.key || '';
+                let cols = [];
+                if (headerMap && headerMap[mapKey]) {
+                  cols = headerMap[mapKey].map(c => c.prop);
+                } else {
+                  const set = new Set();
+                  for (let r of raw) {
+                    if (r && typeof r === 'object') {
+                      Object.keys(r).forEach(k => set.add(k));
+                    }
+                  }
+                  cols = Array.from(set);
+                }
+
+                // 表头
+                const ths = cols.map(c => {
+                  let headLabel = c;
+                  if (headerMap && headerMap[mapKey]) {
+                    const found = headerMap[mapKey].find(h => h.prop === c);
+                    if (found && found.label) headLabel = found.label;
+                  }
+                  return '<th>' + this.escapeHtml(headLabel) + '</th>';
+                }).join('');
+
+                // 行
+                const trs = raw.map(r => {
+                  const tds = cols.map(c => {
+                    const v = (r && r[c] !== undefined && r[c] !== null) ? r[c] : '';
+                    if (typeof v === 'object') return '<td>' + this.escapeHtml(JSON.stringify(v)) + '</td>';
+                    return '<td>' + this.escapeHtml(String(v)) + '</td>';
+                  }).join('');
+                  return '<tr>' + tds + '</tr>';
+                }).join('');
+
+                val = '<table class="pf-table"><thead><tr>' + ths + '</tr></thead><tbody>' + trs + '</tbody></table>';
+              } else {
+                // 其他对象数组,简要渲染
+                val = raw.map(r => {
+                  if (r === null || r === undefined) return '';
+                  if (typeof r === 'string' || typeof r === 'number') return this.escapeHtml(String(r));
+                  if (r && (r.name || r.fileName || r.label)) return this.escapeHtml(r.name || r.fileName || r.label);
+                  if (r && (r.url || r.path)) return '<a href="' + this.escapeHtml(r.url || r.path) + '" target="_blank">文件</a>';
+                  return this.escapeHtml(JSON.stringify(r));
+                }).join(', ');
+              }
+            }
+          } else if (typeof raw === 'object') {
+            if (item.model === 'manage_workorder' && raw.code !== undefined) {
+              val = this.escapeHtml(String(raw.code));
+            } else if (raw.name) {
+              val = this.escapeHtml(String(raw.name));
+            } else if (raw.label) {
+              val = this.escapeHtml(String(raw.label));
+            } else {
+              val = this.escapeHtml(JSON.stringify(raw));
+            }
+          } else {
+            val = this.escapeHtml(String(raw));
+          }
+        } catch (e) {
+          val = '';
+        }
+
+        // 如果是自定义区域(blank)并且渲染结果为表格,则用整行显示区域名称 + 表格
+        const isBlankArea = (item.type === 'blank' || (item.model && String(item.model).indexOf('blank_') === 0));
+        if (isBlankArea && val && String(val).indexOf('<table') !== -1) {
+          // 不显示自定义区域标题,仅渲染表格
+          rows.push('<div class="pf-blank-area">' + val + '</div>');
+        } else {
+          rows.push('<div class="pf-row"><div class="pf-label">' + label + '</div><div class="pf-value">' + val + '</div></div>');
+        }
+      }
+
+      const style = 'body{font-family: -apple-system, BlinkMacSystemFont, "Helvetica Neue", Arial; color:#333} '
+        + '.pf-container{padding:20px;max-width:900px;margin:0 auto} '
+        + '.pf-title{font-size:18px;margin-bottom:12px} '
+        + '.pf-row{display:flex;padding:8px 0;border-bottom:1px solid #eee} '
+        + '.pf-label{width:160px;color:#666} '
+        + '.pf-value{flex:1} '
+        + '.pf-blank-area{margin-top:12px;margin-bottom:12px;overflow:auto} '
+        + '.pf-blank-title{font-weight:600;margin-bottom:6px;color:#333;font-size:14px;word-break:break-word;white-space:normal} '
+        + 'a{color:#1890ff} '
+        + '.pf-table{width:100%;border-collapse:collapse;margin-top:6px} '
+        + '.pf-table th,.pf-table td{border:1px solid #e8e8e8;padding:6px;text-align:left} ';
+
+      return '<!doctype html><html><head><meta charset="utf-8" /><title>' + title + '</title><style>' + style + '</style></head><body>'
+        + '<div class="pf-container"><div class="pf-title">' + title + '</div>' + rows.join('\n') + '</div></body></html>';
+    },
+
+    escapeHtml(str) {
+      if (str === null || str === undefined) return '';
+      return String(str)
+        .replace(/&/g, '&amp;')
+        .replace(/</g, '&lt;')
+        .replace(/>/g, '&gt;')
+        .replace(/"/g, '&quot;')
+        .replace(/'/g, '&#39;');
     },
     getTimelineItemType(result) {
       if (result === '通过') {