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">正在打开报告</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 = '正在确认报告版本…'
      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 = `正在下载${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 = `正在下载${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 = '正在排版 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 = '正在保存当前报告…'
      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;