From 5d9a3141d4b3677bf8155da16b435d2a8818793b Mon Sep 17 00:00:00 2001
From: liding <756868258@qq.com>
Date: 星期五, 23 十月 2026 17:44:08 +0800
Subject: [PATCH] feat(report): 引入 SuperDoc 在线编辑器实现报告审核功能 支持审核页面直接编辑报告 - 新增检测报告与检验报告切换功能,切换时自动保存当前编辑内容 - 检测时间从单日选择改为日期范围选择,兼容历史单日数据回显 - 标准模型新增标准要求值字段,复制模板时携带完整表单数据 - 产品委托单下载报告按钮增加可用性状态控制

---
 src/views/business/reportPreparation/index.vue |  330 +++++++++++++++++++++++++++++++++++++++++++++++-------
 1 files changed, 286 insertions(+), 44 deletions(-)

diff --git a/src/views/business/reportPreparation/index.vue b/src/views/business/reportPreparation/index.vue
index 0ea5aa2..495f00f 100644
--- a/src/views/business/reportPreparation/index.vue
+++ b/src/views/business/reportPreparation/index.vue
@@ -56,8 +56,9 @@
                        :disabled="scope.row.state != 0 || nickName !== scope.row.writeUserName">涓婁紶
             </el-button>
           </el-upload>
-          <!--          <el-button type="text" size="small" :disabled="scope.row.state != 0 || nickName !== scope.row.writeUserName"-->
-          <!--            @click="handleRestore(scope.row)">杩樺師</el-button>-->
+          <el-button v-if="scope.row.urlS && !isBrowserEditedReportUrl(scope.row.urlS)" link type="primary" size="small"
+                     :disabled="scope.row.state != 0 || nickName !== scope.row.writeUserName || !scope.row.url"
+                     @click="handleRestore(scope.row)">杩樺師绯荤粺鎶ュ憡</el-button>
           <el-button link type="primary" size="small" @click="sendBackTask(scope.row)">閫�鍥炰换鍔�</el-button>
           <el-button link type="primary" size="small" :disabled="scope.row.state != 0 || nickName !== scope.row.writeUserName"
                      @click="handle(scope.row)">鎻愪氦
@@ -97,18 +98,46 @@
       </span></template>
     </el-dialog>
     <el-dialog :fullscreen="fullscreen" :modal-append-to-body="false" v-model="issuedVisible" title="鎶ュ憡瀹℃牳"
-               width="80vw">
+               width="90vw" :close-on-click-modal="false" :before-close="closeAuditEditor">
       <div class="full-screen">
         <lims-icon name="el-icon-full-screen" v-if="!fullscreen" style="cursor: pointer;font-size: 18px"
            @click="fullscreen = true;"></lims-icon>
         <img v-else alt="" src="@/assets/images/no-full.svg" style="cursor: pointer;" @click="fullscreen = false;">
       </div>
-      <div v-if="issuedVisible" style="height: 70vh;">
-        <onlyoffice ref="onlyoffice" :options="option" style="width: 100%;height: 100%;"/>
+      <div class="audit-editor-toolbar">
+        <el-radio-group v-if="currentInfo && currentInfo.inspectionUrl"
+                        v-model="currentAuditReportType" :disabled="auditOpening || savingReport || loadingIssued || addApproverDia"
+                        @change="handleAuditReportTypeChange">
+          <el-radio-button :value="0">妫�娴嬫姤鍛�</el-radio-button>
+          <el-radio-button :value="1">妫�楠屾姤鍛�</el-radio-button>
+        </el-radio-group>
+        <span class="margin-tip">淇敼瀛椾綋璇峰厛閫変腑鏂囧瓧鎴栧畾浣嶅厜鏍囷紱淇濆瓨鍚庣敓鏁�</span>
+      </div>
+      <div v-if="issuedVisible" class="audit-editor-content">
+        <report-word-editor v-if="auditDocument" :key="auditDocument.sessionToken" ref="auditWordEditor"
+                            :document="auditDocument" :readonly="savingReport || loadingIssued || addApproverDia"
+                            @ready="onAuditEditorReady" @error="onAuditEditorError"
+                            @change="auditDirty = true" @save="saveAuditDraft"/>
+        <div v-if="auditOpening" class="audit-opening-overlay">
+          <div class="audit-opening-panel" role="status" aria-live="polite">
+            <div class="audit-opening-title">姝e湪鎵撳紑鎶ュ憡</div>
+            <el-progress :percentage="auditOpenProgress" :stroke-width="10"
+                         :indeterminate="auditOpenProgress < 30 || auditOpenProgress >= 80" :duration="3"/>
+            <div class="audit-opening-step">{{ auditOpenText }}</div>
+          </div>
+        </div>
+        <div v-else-if="auditOpenError && !auditDocument" class="audit-opening-overlay">
+          <div class="audit-opening-panel audit-opening-error" role="alert">
+            <div class="audit-opening-title">鎶ュ憡鎵撳紑澶辫触</div>
+            <p>{{ auditOpenError }}</p>
+            <el-button type="primary" @click="handleIssued(currentInfo)">閲嶈瘯</el-button>
+          </div>
+        </div>
       </div>
       <template #footer><span  class="dialog-footer">
-        <el-button :disabled="loadingIssued" @click="issuedReasonVisible = true">涓嶉�氳繃</el-button>
-        <el-button type="primary" @click="subIssued">閫� 杩�</el-button>
+        <el-button :disabled="loadingIssued || savingReport || !auditEditorReady || addApproverDia" @click="saveAuditDraft">淇濆瓨</el-button>
+        <el-button :disabled="auditOpening || !!auditOpenError || loadingIssued || savingReport || addApproverDia" @click="issuedReasonVisible = true">涓嶉�氳繃</el-button>
+        <el-button type="primary" :loading="savingReport" :disabled="loadingIssued || !auditEditorReady" @click="subIssued">閫� 杩�</el-button>
       </span></template>
     </el-dialog>
     <el-dialog :modal-append-to-body="false" v-model="issuedReasonVisible" title="涓嶉�氳繃鍘熷洜" width="400px">
@@ -266,11 +295,15 @@
 
 <script>
 import onlyoffice from "@/components/Onlyoffice/onlyoffice.vue";
+import ReportWordEditor from "@/components/ReportWordEditor/index.vue";
 // import ShowInfo from "../do/b1-material-ins-order/showInfo.vue";
 import {
   downAll,
   downloadReport,
   examineReport,
+  getReportEditDocument,
+  saveReportEditDocument,
+  getReportEditConfig,
   pageInsReport,
   regenerateReport,
   ratifyReport,
@@ -284,10 +317,11 @@
 import limsTable from "@/components/Table/lims-table.vue";
 import {delFile, downFile, getFileList} from "@/api/business/rawMaterialOrder";
 import {selectEnabledSeals} from "@/api/structural/sealConfig";
+import {loadSuperDoc} from "@/utils/loadSuperDoc";
 
 export default {
   name: 'ReportPreparation',
-  components: {limsTable, onlyoffice},
+  components: {limsTable, onlyoffice, ReportWordEditor},
   dicts: ['material_prop_type'],
   data() {
     return {
@@ -328,6 +362,18 @@
       loadingApprove: false,
       loadingIssuedReason: false,
       loadingIssued: false,
+      savingReport: false,
+      auditEditorReady: false,
+      auditOpening: false,
+      auditOpenProgress: 0,
+      auditOpenText: '',
+      auditOpenError: '',
+      auditOpenGeneration: 0,
+      currentAuditReportType: 0,
+      loadedAuditReportType: null,
+      auditEditSessions: {},
+      auditDocument: null,
+      auditDirty: false,
       value: ``,
       reason: '',
       currentInfo: null,
@@ -871,8 +917,9 @@
           reader.readAsText(res)
           return
         }
-        const reportUrl = reportType === 1 ? row.inspectionUrl : (row.urlS || row.url || '')
-        const filename = decodeURIComponent(reportUrl.substring(reportUrl.lastIndexOf('/') + 1)) || `${row.code}.docx`
+        const reportNumber = String(row.code || '鎶ュ憡').replace(/[\\/:*?"<>|\x00-\x1f]/g, '-').trim()
+        const reportSuffix = row.inspectionUrl ? (reportType === 1 ? '-妫�楠屾姤鍛�' : '-妫�娴嬫姤鍛�') : ''
+        const filename = `${reportNumber}${reportSuffix}.docx`
         this.$download.saveAs(res, filename)
         this.downloadDialogVisible = false
       }).catch(() => {
@@ -885,7 +932,15 @@
       })
     },
     handleRegenerate(row) {
-      this.$confirm('灏嗘牴鎹綋鍓嶅師濮嬭褰曢噸鏂扮敓鎴愬苟瑕嗙洊绯荤粺鎶ュ憡锛屾槸鍚︾户缁紵', '鎻愮ず', {
+      if (row.urlS && !this.isBrowserEditedReportUrl(row.urlS)) {
+        this.$message.warning('褰撳墠浣跨敤鎵嬪姩涓婁紶鐨勬姤鍛婏紝璇峰厛鐐瑰嚮鈥滆繕鍘熺郴缁熸姤鍛娾�濓紝鍐嶉噸鏂扮敓鎴�')
+        return
+      }
+      const replacingEditedReport = this.isBrowserEditedReportUrl(row.urlS)
+      const prompt = replacingEditedReport
+        ? '閲嶆柊鐢熸垚灏嗚鐩栧鏍搁〉闈繚瀛樼殑淇敼锛屾寜褰撳墠鍘熷璁板綍鐢熸垚鏂版姤鍛娿�傛槸鍚︾户缁紵'
+        : '灏嗘牴鎹綋鍓嶅師濮嬭褰曢噸鏂扮敓鎴愬苟瑕嗙洊绯荤粺鎶ュ憡锛屾槸鍚︾户缁紵'
+      this.$confirm(prompt, '鎻愮ず', {
         confirmButtonText: '纭畾',
         cancelButtonText: '鍙栨秷',
         type: 'warning'
@@ -900,40 +955,153 @@
       }).catch(() => {
       })
     },
-    // 杩樺師鎿嶄綔
+    isBrowserEditedReportUrl(url) {
+      return /^\/word\/(?:edited|reviewed)-[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}\.docx$/i.test(url || '')
+    },
+    // 鍋滅敤鎵嬪姩涓婁紶鐨勬姤鍛婏紝鎭㈠浣跨敤绯荤粺鐢熸垚鐨勭増鏈��
     handleRestore(row) {
-      upReportUrl({
-        id: row.id
-      }).then(res => {
-        if (res.code === 200) {
-          this.$message.success('鎿嶄綔鎴愬姛')
-          this.refreshTable('page')
-        }
+      this.$confirm('杩樺師鍚庡皢浣跨敤鍘熺郴缁熸姤鍛婏紝鎵嬪姩涓婁紶鐨勭増鏈皢涓嶅啀浣滀负褰撳墠鎶ュ憡銆傛槸鍚︾户缁紵', '杩樺師绯荤粺鎶ュ憡', {
+        confirmButtonText: '纭畾',
+        cancelButtonText: '鍙栨秷',
+        type: 'warning'
+      }).then(() => {
+        return upReportUrl({ id: row.id }).then(res => {
+          if (res.code === 200) {
+            this.$message.success('宸茶繕鍘熺郴缁熸姤鍛婏紝鍙互閲嶆柊鐢熸垚')
+            this.refreshTable('page')
+          }
+        })
+      }).catch(() => {
       })
     },
     // 瀹℃牳鎸夐挳
-    handleIssued(row) {
-      this.currentInfo = row;
-      let fileName = (row.urlS === null || row.urlS === '') ? row.url : row.urlS
-      let fileType = "docx"
-      if (row.tempUrlPdf != null || row.tempUrlPdf === '') {
-        fileName = row.tempUrlPdf
-        fileType = "pdf"
+    async handleIssued(row) {
+      const generation = ++this.auditOpenGeneration
+      this.currentInfo = row
+      this.auditEditSessions = {}
+      this.auditDocument = null
+      this.auditEditorReady = false
+      this.auditDirty = false
+      this.loadedAuditReportType = null
+      this.currentAuditReportType = 0
+      this.auditOpening = true
+      this.auditOpenProgress = 5
+      this.auditOpenText = '姝e湪纭鎶ュ憡鐗堟湰鈥�'
+      this.auditOpenError = ''
+      this.issuedVisible = true
+      // 璁╃紪杈戝櫒鑴氭湰涓庢姤鍛婄増鏈姹傚悓鏃跺姞杞斤紝閬垮厤涓嬭浇瀹� Word 鍚庡啀绛夊緟鑴氭湰銆�
+      loadSuperDoc().catch(() => {})
+      try {
+        // 涓や唤鎶ュ憡閮界‘璁ょ増鏈紱鏈墦寮�鐨勬姤鍛婃部鐢ㄦ湇鍔$鎸佷箙鍖栫増鏈��
+        const types = row.inspectionUrl ? [0, 1] : [0]
+        const configs = await Promise.all(types.map(type => getReportEditConfig(row.id, type)))
+        if (generation !== this.auditOpenGeneration || !this.issuedVisible) return
+        types.forEach((type, index) => { this.auditEditSessions[type] = configs[index].data })
+        this.auditOpenProgress = 30
+        await this.loadAuditEditor(0, generation)
+      } catch (e) {
+        if (generation !== this.auditOpenGeneration || !this.issuedVisible) return
+        this.auditOpening = false
+        this.auditOpenError = e.message || '鎶ュ憡鎵撳紑澶辫触锛岃閲嶈瘯'
       }
-      fileName = fileName.replace('/word/', '')
-      const userName = this.nickName
-      this.option = {
-        url: this.javaApi + "/word/" + fileName,
-        isEdit: false,
-        fileType: fileType,
-        title: fileName,
-        lang: 'zh-CN',
-        isPrint: false,
-        user_id: 1,
-        user_name: userName,
-        editUrl: this.javaApi + "/insReport/onlyOffice/save?fileName=" + fileName
+    },
+    async loadAuditEditor(reportType, generation = this.auditOpenGeneration) {
+      this.auditEditorReady = false
+      this.auditOpening = true
+      this.auditOpenProgress = Math.max(this.auditOpenProgress, 30)
+      const reportName = reportType === 0 ? '妫�娴嬫姤鍛�' : '妫�楠屾姤鍛�'
+      this.auditOpenText = `姝e湪涓嬭浇${reportName}鈥
+      const config = this.auditEditSessions[reportType]
+      const blob = await getReportEditDocument(config.sessionToken, event => {
+        if (generation !== this.auditOpenGeneration || !this.issuedVisible) return
+        const ratio = event.total > 0 ? event.loaded / event.total : event.progress
+        if (typeof ratio === 'number' && Number.isFinite(ratio)) {
+          this.auditOpenProgress = Math.max(this.auditOpenProgress, 30 + Math.round(Math.min(1, ratio) * 45))
+          this.auditOpenText = `姝e湪涓嬭浇${reportName}锛�${Math.round(Math.min(1, ratio) * 100)}%锛夆�
+        }
+      })
+      if (generation !== this.auditOpenGeneration || !this.issuedVisible) return false
+      if (!(blob instanceof Blob)) throw new Error('璇诲彇鎶ュ憡澶辫触')
+      if (blob.type.includes('json')) {
+        const error = JSON.parse(await blob.text())
+        throw new Error(error.msg || '璇诲彇鎶ュ憡澶辫触')
       }
-      this.issuedVisible = true;
+      this.auditOpenProgress = 80
+      this.auditOpenText = '姝e湪鎺掔増 Word 鏂囨。鈥�'
+      this.auditDocument = { ...config, blob, code: this.currentInfo?.code }
+      this.loadedAuditReportType = reportType
+      this.currentAuditReportType = reportType
+      this.auditDirty = false
+      return true
+    },
+    onAuditEditorReady() {
+      if (!this.issuedVisible || !this.auditDocument) return
+      this.auditEditorReady = true
+      this.auditOpenProgress = 100
+      this.auditOpenText = '鎶ュ憡宸叉墦寮�'
+      this.auditOpenError = ''
+      this.auditOpening = false
+    },
+    onAuditEditorError(error) {
+      if (!this.issuedVisible || !this.auditDocument) return
+      this.auditEditorReady = false
+      this.auditOpening = false
+      this.auditOpenError = error || 'Word 缂栬緫鍣ㄥ姞杞藉け璐�'
+    },
+    async handleAuditReportTypeChange(reportType) {
+      const previous = this.loadedAuditReportType
+      if (Number(reportType) === Number(previous)) return
+      this.savingReport = true
+      this.auditOpening = true
+      this.auditOpenProgress = 10
+      this.auditOpenText = '姝e湪淇濆瓨褰撳墠鎶ュ憡鈥�'
+      this.auditOpenError = ''
+      try {
+        await this.saveCurrentAuditReport()
+        await this.loadAuditEditor(Number(reportType))
+      } catch (e) {
+        this.currentAuditReportType = previous
+        this.auditEditorReady = !!this.auditDocument
+        this.auditOpening = false
+        this.auditOpenError = ''
+        this.$message.error(e.message || '鎶ュ憡淇濆瓨澶辫触锛屾棤娉曞垏鎹�')
+      } finally {
+        this.savingReport = false
+      }
+    },
+    async saveCurrentAuditReport() {
+      if (!this.auditEditorReady || !this.$refs.auditWordEditor) throw new Error('鎶ュ憡灏氭湭鍔犺浇瀹屾垚')
+      const config = this.auditEditSessions[this.loadedAuditReportType]
+      const blob = await this.$refs.auditWordEditor.exportDocx()
+      const res = await saveReportEditDocument(config.sessionToken, config.version, blob)
+      config.version = res.data.version
+      this.auditDirty = false
+    },
+    async saveAuditDraft() {
+      if (this.savingReport) return
+      this.savingReport = true
+      try {
+        await this.saveCurrentAuditReport()
+        this.$message.success('鎶ュ憡宸蹭繚瀛�')
+        this.refreshTable('page')
+      } catch (e) {
+        this.$message.error(e.message || '鎶ュ憡淇濆瓨澶辫触')
+      } finally {
+        this.savingReport = false
+      }
+    },
+    async closeAuditEditor(done) {
+      if (this.savingReport || this.loadingIssued || this.addApproverDia) return
+      if (this.auditDirty) {
+        try {
+          await this.$confirm('鏈夊皻鏈繚瀛樼殑缂栬緫锛屽叧闂皢涓㈠純杩欎簺淇敼銆傜‘璁ゅ叧闂紵', '鍏抽棴瀹℃牳')
+        } catch (_) { return }
+      }
+      this.auditOpenGeneration++
+      this.auditOpening = false
+      this.auditOpenError = ''
+      this.auditDocument = null
+      done()
     },
     // 鏌ョ湅鎶ュ憡
     viewIssued(row) {
@@ -983,7 +1151,7 @@
       });
     },
     // 瀹℃牳閫氳繃
-    submitAddApprover() {
+    async submitAddApprover() {
       if (!this.approver) {
         this.$message.error('璇烽�夋嫨瀹℃壒浜�')
         return
@@ -992,11 +1160,21 @@
         this.$message.error('瀹℃牳浜哄拰鎵瑰噯浜轰笉鑳戒负鍚屼竴浜猴紝璇烽噸鏂伴�夋嫨鎵瑰噯浜�')
         return
       }
+      if (this.loadingIssued || this.savingReport) return
       this.loadingIssued = true;
+      try {
+        // 閫夋嫨鎵瑰噯浜哄悗鍐嶆瀵煎嚭锛屽鏍稿缁堢粦瀹氬綋鍓嶇紪杈戝唴瀹广��
+        await this.saveCurrentAuditReport()
+      } catch (e) {
+        this.loadingIssued = false
+        this.$message.error(e.message || '鎶ュ憡淇濆瓨澶辫触')
+        return
+      }
       examineReport({
         id: this.currentInfo.id,
         userId: this.approver,
-        isExamine: 1
+        isExamine: 1,
+        editSessions: Object.fromEntries(Object.entries(this.auditEditSessions).map(([type, config]) => [type, config.sessionToken]))
       }).then(res => {
         this.loadingIssued = false;
         this.$message.success('鎻愪氦鎴愬姛')
@@ -1013,9 +1191,22 @@
       this.approver = ''
     },
     // 鐐瑰嚮閫氳繃锛岄渶瑕侀�夋嫨鎵瑰噯浜哄憳
-    subIssued() {
-      this.getAuthorizedPerson(true)
-      this.addApproverDia = true
+    async subIssued() {
+      if (this.savingReport || this.loadingIssued) return
+      if (!this.auditEditorReady) {
+        this.$message.error('鎶ュ憡灏氭湭鍔犺浇瀹屾垚锛岃绋嶅悗閲嶈瘯')
+        return
+      }
+      this.savingReport = true
+      try {
+        await this.saveCurrentAuditReport()
+        this.getAuthorizedPerson(true)
+        this.addApproverDia = true
+      } catch (e) {
+        this.$message.error(e.message || 'Word 鎶ュ憡淇濆瓨澶辫触锛屾殏涓嶈兘瀹℃牳閫氳繃')
+      } finally {
+        this.savingReport = false
+      }
     },
     // 鑾峰彇浜哄憳鍒楄〃
     getAuthorizedPerson(filterExamine) {
@@ -1237,6 +1428,57 @@
   top: 22px;
 }
 
+.audit-editor-toolbar {
+  min-height: 36px;
+  display: flex;
+  align-items: center;
+  gap: 16px;
+  margin-bottom: 8px;
+}
+
+.audit-editor-content {
+  position: relative;
+  height: 70vh;
+}
+
+.audit-opening-overlay {
+  position: absolute;
+  inset: 0;
+  z-index: 20;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  background: #fff;
+}
+
+.audit-opening-panel {
+  width: min(420px, calc(100% - 48px));
+  text-align: center;
+}
+
+.audit-opening-title {
+  margin-bottom: 20px;
+  font-size: 18px;
+  font-weight: 600;
+  color: #303133;
+}
+
+.audit-opening-step {
+  margin-top: 12px;
+  color: #606266;
+  font-size: 13px;
+}
+
+.audit-opening-error p {
+  margin: 0 0 20px;
+  color: #606266;
+}
+
+.margin-tip {
+  color: #606266;
+  font-size: 13px;
+}
+
 .btns {
   position: absolute;
   right: 40px;

--
Gitblit v1.9.3