liding
4 天以前 6eeb82e56306d645a290a0f5ba202efe3ef2385d
feat(report): 新增PDF报告预览功能并重构报告下载流程
已添加3个文件
已修改13个文件
729 ■■■■■ 文件已修改
package.json 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/business/insReport.js 13 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/cnas/process/reportResults.js 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/components/ReportPreview/PdfViewer.vue 193 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/components/ReportPreview/index.vue 123 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/permission.js 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/utils/reportPreview.js 40 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/utils/request.js 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/CNAS/process/reportResults/index.vue 21 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/business/inspectionTask/index.vue 14 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/business/materialOrder/index.vue 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/business/materialOrderComponents/materialOrder/downFileDialog.vue 99 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/business/productOrder/index.vue 22 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/business/reportPreparation/index.vue 183 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tests/attachment-download.test.mjs 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
vite.config.js 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
package.json
@@ -41,6 +41,7 @@
    "mammoth": "^1.13.0",
    "nprogress": "0.2.0",
    "pako": "^3.0.2",
    "pdfjs-dist": "5.7.284",
    "pinia": "3.0.4",
    "print-js": "^1.6.0",
    "qrcode.vue": "^3.11.0",
src/api/business/insReport.js
@@ -27,12 +27,21 @@
}
// å•份报告下载
export function downloadReport(id, reportType) {
export function downloadReport(id, reportType, signal) {
  return request({
    url: '/insReport/download',
    method: 'get',
    params: { id, reportType },
    signal,
    responseType: 'blob'
  })
}
// æŒ‰ Word ç‰ˆå¼ç”Ÿæˆåˆ†é¡µ PDF,沿用报告下载的数据范围。
export function previewReportPdf(id, reportType, signal) {
  return request({
    url: '/insReport/preview', method: 'get',
    params: { id, reportType }, signal, responseType: 'blob'
  })
}
@@ -78,7 +87,7 @@
    data: { id }
  })
}
// æŠ¥å‘Šé€€å›žåˆ°æ£€éªŒä»»åŠ¡
// æŠ¥å‘Šé€€å›žåˆ°æ£€éªŒä»»åŠ¡æˆ–æŠ¥å‘Šå®¡æ ¸
export function sendBackTask(query) {
  return request({
    url: '/insReport/sendBackTask',
src/api/cnas/process/reportResults.js
@@ -48,11 +48,12 @@
}
// ä¸‹è½½æ‰¹å‡†åŽç”Ÿæˆçš„æœ‰ç« æŠ¥å‘Š
export function downloadStampedReport(id, format) {
export function downloadStampedReport(id, format, signal) {
  return request({
    url: "/processReport/downloadStampedReport",
    method: "get",
    params: { id, format },
    signal,
    responseType: "blob",
  });
}
src/components/ReportPreview/PdfViewer.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,193 @@
<template>
  <div class="report-pdf-viewer">
    <div class="pdf-toolbar">
      <el-button size="small" :disabled="currentPage <= 1" @click="goToPage(currentPage - 1)">上一页</el-button>
      <span>{{ currentPage }} / {{ pages.length || '—' }}</span>
      <el-button size="small" :disabled="currentPage >= pages.length" @click="goToPage(currentPage + 1)">下一页</el-button>
      <span class="toolbar-spacer" />
      <el-button size="small" :disabled="zoom <= 0.5" @click="zoom = Math.max(0.5, zoom - 0.25)">缩小</el-button>
      <el-button size="small" @click="zoom = 1">{{ Math.round(zoom * 100) }}%</el-button>
      <el-button size="small" :disabled="zoom >= 2" @click="zoom = Math.min(2, zoom + 0.25)">放大</el-button>
    </div>
    <div ref="viewport" v-loading="loading" element-loading-text="正在加载报告…" class="pdf-viewport" @scroll="updatePage">
      <el-result v-if="error" icon="warning" title="报告预览失败" :sub-title="error">
        <template #extra><el-button @click="openDocument">重试</el-button></template>
      </el-result>
      <div v-for="page in pages" :key="page.number" class="pdf-paper" :data-page-number="page.number"
           :style="{ width: pageWidth(page) + 'px', height: pageWidth(page) * page.height / page.width + 'px' }">
        <canvas :ref="element => setCanvas(element, page.number)" :data-page-number="page.number"
                role="img" :aria-label="'报告第 ' + page.number + ' é¡µ'" />
      </div>
    </div>
  </div>
</template>
<script setup>
import { ref, nextTick, watch, onMounted, onBeforeUnmount } from 'vue'
import workerUrl from 'pdfjs-dist/build/pdf.worker.min.mjs?worker&url'
import { loadReportFile, validateReportPreview } from '@/utils/reportPreview'
const props = defineProps({ source: { default: null }, load: Function })
const emit = defineEmits(['ready', 'error'])
const viewport = ref(null)
const pages = ref([])
const loading = ref(false)
const error = ref('')
const zoom = ref(1)
const availableWidth = ref(800)
const currentPage = ref(1)
const canvases = new Map()
const rendering = new Map()
const completed = new Set()
let generation = 0, renderGeneration = 0, pdf = null, task = null, controller = null
let observer = null, resizeObserver = null, resizeTimer = null
// PDF çš„ 72pt è½¬ä¸ºå±å¹• 96px:A4 çº¸é»˜è®¤çº¦ 794px,窄窗口只缩小,不自动放大。
function pageWidth(page) { return Math.min(page.width * zoom.value, Math.max(1, availableWidth.value - 32)) }
function setCanvas(element, number) { if (element) canvases.set(number, element); else canvases.delete(number) }
function cancelRendering() {
  renderGeneration++
  observer?.disconnect()
  for (const item of rendering.values()) item.cancel()
  rendering.clear()
  completed.clear()
}
function dispose() {
  generation++
  controller?.abort()
  cancelRendering()
  task?.destroy().catch(() => {})
  task = null
  pdf = null
  for (const canvas of canvases.values()) { canvas.width = 0; canvas.height = 0 }
  canvases.clear()
}
async function renderPage(number, documentGeneration, viewGeneration) {
  if (!pdf || completed.has(number) || rendering.has(number)) return
  completed.add(number)
  try {
    const page = await pdf.getPage(number)
    if (documentGeneration !== generation || viewGeneration !== renderGeneration) return
    const canvas = canvases.get(number)
    if (!canvas) return
    delete canvas.dataset.rendered
    const spec = pages.value[number - 1]
    const scale = pageWidth(spec) / spec.width * 96 / 72
    const density = Math.min(window.devicePixelRatio || 1, 2)
    const size = page.getViewport({ scale })
    canvas.width = Math.ceil(size.width * density)
    canvas.height = Math.ceil(size.height * density)
    const render = page.render({ canvasContext: canvas.getContext('2d'), viewport: size,
      transform: [density, 0, 0, density, 0, 0], background: '#ffffff' })
    rendering.set(number, render)
    await render.promise
    if (documentGeneration === generation && viewGeneration === renderGeneration) {
      canvas.dataset.rendered = 'true'
      if (number === 1) { loading.value = false; emit('ready') }
    }
  } catch (cause) {
    if (documentGeneration !== generation || viewGeneration !== renderGeneration) return
    if (cause.name !== 'RenderingCancelledException') {
      error.value = '报告页面加载失败,请重试'
      loading.value = false
      emit('error', error.value)
    }
  } finally {
    if (documentGeneration === generation && viewGeneration === renderGeneration) rendering.delete(number)
  }
}
async function renderVisiblePages() {
  if (!pdf || !viewport.value) return
  const cancelled = [...rendering.values()].map(item => item.promise.catch(() => {}))
  cancelRendering()
  const documentGeneration = generation, viewGeneration = renderGeneration
  await Promise.all(cancelled)
  await nextTick()
  if (documentGeneration !== generation || viewGeneration !== renderGeneration) return
  observer = new IntersectionObserver(entries => {
    for (const entry of entries) if (entry.isIntersecting) {
      renderPage(Number(entry.target.dataset.pageNumber), documentGeneration, viewGeneration)
    }
  }, { root: viewport.value, rootMargin: '300px' })
  for (const canvas of canvases.values()) observer.observe(canvas)
  if (loading.value) renderPage(1, documentGeneration, viewGeneration)
}
async function openDocument() {
  dispose()
  const version = generation
  controller = new AbortController()
  pages.value = []
  currentPage.value = 1
  zoom.value = 1
  error.value = ''
  loading.value = true
  try {
    const content = props.load ? await props.load(controller.signal)
      : props.source instanceof Blob ? props.source : await loadReportFile(props.source, controller.signal)
    const blob = await validateReportPreview(content, 'pdf')
    const library = await import('pdfjs-dist/build/pdf.mjs')
    const bytes = new Uint8Array(await blob.arrayBuffer())
    if (version !== generation) return
    library.GlobalWorkerOptions.workerSrc = workerUrl
    task = library.getDocument({ data: bytes, useWasm: false, useSystemFonts: true, isEvalSupported: false })
    const document = await task.promise
    if (version !== generation) return
    pdf = document
    const sizes = []
    for (let number = 1; number <= document.numPages; number++) {
      const page = await document.getPage(number)
      if (version !== generation) return
      const size = page.getViewport({ scale: 96 / 72 })
      sizes.push({ number, width: size.width, height: size.height })
    }
    pages.value = sizes
    await nextTick()
    viewport.value.scrollTop = 0
    await renderVisiblePages()
  } catch (cause) {
    if (version !== generation) return
    error.value = cause.message || '报告加载失败,请重试'
    loading.value = false
    emit('error', error.value)
  }
}
function goToPage(number) {
  const target = viewport.value?.querySelector(`[data-page-number="${number}"]`)
  if (target) { viewport.value.scrollTop = target.offsetTop - 16; currentPage.value = number }
}
function updatePage() {
  const top = viewport.value.getBoundingClientRect().top
  for (const paper of viewport.value.querySelectorAll('.pdf-paper')) {
    if (paper.getBoundingClientRect().bottom > top + 32) {
      currentPage.value = Number(paper.dataset.pageNumber)
      break
    }
  }
}
watch(() => [props.source, props.load], () => { if (viewport.value) openDocument() })
watch(zoom, renderVisiblePages)
onMounted(() => {
  availableWidth.value = viewport.value.clientWidth
  resizeObserver = new ResizeObserver(() => {
    const width = viewport.value?.clientWidth
    if (!width || width === availableWidth.value) return
    const previousWidths = pages.value.map(pageWidth)
    availableWidth.value = width
    if (pages.value.every((page, index) => pageWidth(page) === previousWidths[index])) return
    clearTimeout(resizeTimer)
    resizeTimer = setTimeout(renderVisiblePages, 100)
  })
  resizeObserver.observe(viewport.value)
  openDocument()
})
onBeforeUnmount(() => { clearTimeout(resizeTimer); resizeObserver?.disconnect(); dispose() })
</script>
<style scoped>
.report-pdf-viewer { display: flex; flex-direction: column; width: 100%; height: 100%; min-height: 0; background: #fff; color: #303133; color-scheme: light; }
.pdf-toolbar { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; flex: 0 0 auto; padding: 8px 12px; background: #fff; border-bottom: 1px solid #ebeef5; }
.toolbar-spacer { flex: 1; }
.pdf-viewport { flex: 1; min-height: 0; overflow: auto; position: relative; background: #fff; }
.pdf-paper { position: relative; margin: 16px auto; background: #fff; box-shadow: 0 0 0 1px #e4e7ed; }
.pdf-paper canvas { display: block; width: 100%; height: 100%; background: #fff; }
</style>
src/components/ReportPreview/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,123 @@
<template>
  <el-dialog v-model="visible" title="报告预览" width="90vw" :fullscreen="fullscreen"
             append-to-body destroy-on-close :close-on-click-modal="false" @close="reset">
    <div class="report-preview-toolbar">
      <el-radio-group v-if="variants.length > 1" :model-value="selectedKey" @change="selectReport">
        <el-radio-button v-for="item in variants" :key="item.key" :value="item.key">{{ item.label }}</el-radio-button>
      </el-radio-group>
      <span class="report-preview-name">{{ current?.filename }}</span>
      <el-button @click="fullscreen = !fullscreen">{{ fullscreen ? '退出全屏' : '全屏查看' }}</el-button>
      <el-button type="primary" :disabled="!blob || loading" :loading="downloading" @click="download">下载当前报告</el-button>
    </div>
    <div v-loading="loading" element-loading-text="正在加载报告…" class="report-preview-content"
         :class="{ 'is-fullscreen': fullscreen }">
      <el-result v-if="error" icon="warning" title="报告预览失败" :sub-title="error">
        <template #extra><el-button @click="selectReport(selectedKey, true)">重试</el-button></template>
      </el-result>
      <pdf-viewer v-else-if="blob" :source="blob" />
    </div>
  </el-dialog>
</template>
<script>
import { validateReportPreview } from '@/utils/reportPreview'
import PdfViewer from './PdfViewer.vue'
export default {
  name: 'ReportPreview',
  components: { PdfViewer },
  data() {
    return {
      visible: false, fullscreen: false, variants: [], selectedKey: null,
      loading: false, downloading: false, error: '', blob: null, objectUrl: '',
      generation: 0, controller: null, downloadController: null, cache: new Map()
    }
  },
  computed: {
    current() { return this.variants.find(item => item.key === this.selectedKey) }
  },
  beforeUnmount() { this.reset() },
  methods: {
    open(variants, selectedKey = variants[0].key) {
      this.reset()
      this.variants = variants
      this.fullscreen = false
      this.visible = true
      return this.selectReport(selectedKey)
    },
    releaseUrl() {
      if (this.objectUrl) URL.revokeObjectURL(this.objectUrl)
      this.objectUrl = ''
      this.blob = null
    },
    reset() {
      this.generation++
      this.controller?.abort()
      this.downloadController?.abort()
      this.controller = null
      this.downloadController = null
      this.releaseUrl()
      this.cache.clear()
      this.loading = false
      this.downloading = false
      this.error = ''
    },
    async selectReport(key, retry = false) {
      this.controller?.abort()
      this.downloadController?.abort()
      this.downloading = false
      const generation = ++this.generation
      this.selectedKey = key
      this.releaseUrl()
      this.error = ''
      this.loading = true
      const controller = new AbortController()
      this.controller = controller
      const item = this.current
      try {
        let blob = retry ? null : this.cache.get(key)
        if (!blob) blob = await validateReportPreview(await item.load(controller.signal), 'pdf')
        // å…³é—­å¼¹çª—、快速切换报告或打开另一行后,旧请求不得覆盖当前内容。
        if (generation !== this.generation) return
        this.cache.set(key, blob)
        this.blob = blob
        this.objectUrl = URL.createObjectURL(blob)
      } catch (error) {
        if (generation !== this.generation) return
        const status = error.response?.status
        this.error = status === 404 ? '报告文件不存在,请联系管理员'
          : status === 401 || status === 403 ? '登录已过期或没有报告查看权限'
          : error.message || '报告加载失败,请稍后重试'
      } finally {
        if (generation === this.generation) this.loading = false
      }
    },
    async download() {
      if (!this.blob || this.loading || this.downloading) return
      const generation = this.generation
      const item = this.current
      this.downloading = true
      const controller = new AbortController()
      this.downloadController = controller
      try {
        // é¢„览统一显示 PDF;报告编制仍下载原 Word,其他入口复用已加载的 PDF。
        const blob = item.download
          ? await validateReportPreview(await item.download(controller.signal), item.downloadFormat || 'docx')
          : this.blob
        if (generation === this.generation) this.$download.saveAs(blob, item.filename)
      } catch (error) {
        if (generation === this.generation) this.$message.error(error.message || '报告下载失败')
      } finally {
        if (generation === this.generation) this.downloading = false
      }
    }
  }
}
</script>
<style scoped>
.report-preview-toolbar { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; margin-bottom: 12px; }
.report-preview-name { flex: 1; min-width: 120px; overflow-wrap: anywhere; color: var(--el-text-color-secondary); }
.report-preview-content { height: 75vh; min-height: 300px; overflow: auto; background: #fff; }
.report-preview-content.is-fullscreen { height: calc(100vh - 160px); }
</style>
src/permission.js
@@ -75,3 +75,9 @@
router.afterEach(() => {
  NProgress.done()
})
router.onError(error => {
  NProgress.done()
  console.error('[router] é¡µé¢åŠ è½½å¤±è´¥ï¼š', error)
  ElMessage.error('页面加载失败,请刷新页面后重试')
})
src/utils/reportPreview.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,40 @@
import axios from 'axios'
import { getToken } from '@/utils/auth'
import { getJavaApi } from '@/utils/java-api'
import { resolveAttachmentUrl, validateAttachmentBlob } from '@/utils/attachment'
export function reportFilename(value, extension) {
  const name = String(value || '报告').replace(/[\\/:*?"<>|\x00-\x1f]/g, '-').trim() || '报告'
  return name.replace(/\.(docx|pdf)$/i, '') + '.' + extension
}
// åªæ›¿æ¢æœ«å°¾æ‰©å±•名,兼容目录名、报告编号中含小数点的历史文件。
export function reportPdfPath(path) {
  return path.replace(/\.docx?$/i, '.pdf')
}
// ä¸‹å•页沿用已有报告文件入口,鉴权信息只发送给本系统。
export async function loadReportFile(path, signal) {
  const url = resolveAttachmentUrl(path, 2)
  const backend = new URL(getJavaApi(), window.location.origin)
  const target = new URL(url, window.location.origin)
  const isBackend = target.origin === backend.origin
    && target.pathname.startsWith(backend.pathname.replace(/\/$/, '') + '/')
  const { data } = await axios.get(url, {
    responseType: 'blob', signal,
    headers: isBackend && getToken() ? { Authorization: 'Bearer ' + getToken() } : {}
  })
  return data
}
export async function validateReportPreview(blob, format) {
  if (!(blob instanceof Blob) || !blob.size) throw new Error('报告内容为空,请重试或联系管理员')
  await validateAttachmentBlob(blob, '报告.' + format)
  const header = new Uint8Array(await blob.slice(0, 5).arrayBuffer())
  const isPdf = String.fromCharCode(...header) === '%PDF-'
  const isDocx = header[0] === 0x50 && header[1] === 0x4b
  if (format === 'pdf' ? !isPdf : !isDocx) throw new Error('未获取到有效的报告文件,请重试或联系管理员')
  // é™æ€æ–‡ä»¶æœåŠ¡å™¨å¯èƒ½è¿”å›ž octet-stream,PDF é˜…读器需要明确的 MIME ç±»åž‹ã€‚
  return new Blob([blob], { type: format === 'pdf' ? 'application/pdf'
    : 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' })
}
src/utils/request.js
@@ -115,6 +115,8 @@
    }
  },
  error => {
    // é¢„览窗口关闭或切换报告会主动取消读取,不提示为接口异常。
    if (axios.isCancel(error)) return Promise.reject(error)
    console.log('err' + error)
    let { message } = error
    if (message == "Network Error") {
src/views/CNAS/process/reportResults/index.vue
@@ -114,14 +114,18 @@
      </el-form>
      <template #footer><span  class="dialog-footer">
        <el-button @click="downloadDialogVisible = false">取 æ¶ˆ</el-button>
        <el-button :disabled="!downloadRow" @click="previewReport(downloadRow)">预览有章报告</el-button>
        <el-button type="primary" :loading="downloadingReportId !== null"
                   @click="downloadSelectedReport">下 è½½</el-button>
      </span></template>
    </el-dialog>
    <report-preview ref="reportPreview" />
  </div>
</template>
<script>
import ReportPreview from '@/components/ReportPreview/index.vue'
import { reportFilename } from '@/utils/reportPreview'
import limsTable from "@/components/Table/lims-table.vue";
import {
  exportProcessReport,
@@ -137,7 +141,8 @@
export default {
  name: 'ReportResults',
  components: {
    limsTable
    limsTable,
    ReportPreview
  },
  data() {
    return {
@@ -171,8 +176,13 @@
          dataType: "action",
          fixed: "right",
          label: "操作",
          width: "180px",
          width: "230px",
          operation: [
            {
              name: "预览",
              type: "text",
              clickFun: row => this.previewReport(row),
            },
            {
              name: "下载",
              type: "text",
@@ -305,6 +315,13 @@
      this.addInfo = { ...row }
      this.addDialogVisible = true;
    },
    previewReport(row) {
      this.$refs.reportPreview.open([{
        key: 'stamped', label: '有章报告', format: 'pdf',
        filename: reportFilename(row.insReportCode || '有章报告', 'pdf'),
        load: signal => downloadStampedReport(row.id, 'pdf', signal)
      }])
    },
    handleDownload(row) {
      this.downloadRow = row
      this.selectedDownloadFormat = 'pdf'
src/views/business/inspectionTask/index.vue
@@ -417,7 +417,7 @@
          },
        },
        {
          label: "状态",
          label: "检验状态",
          prop: "insState",
          dataType: "tag",
          formatData: (params) => {
@@ -429,6 +429,18 @@
            return item ? item.type : "info";
          },
        },
        {
          label: "报告退回状态",
          prop: "reportReturnSource",
          width: "140px",
          dataType: "tag",
          formatData: (source) => {
            if (source === "APPROVED") return "报告批准退回";
            if (source === "REPORT_REVIEW") return "报告审核退回";
            return null;
          },
          formatType: () => "warning",
        },
        { label: "检验人", prop: "userName" },
        { label: "复核人", prop: "checkName" },
        { label: "约定时间", prop: "appointed" },
src/views/business/materialOrder/index.vue
@@ -552,7 +552,7 @@
              }
            },
            {
              name: '报告下载',
              name: '报告预览/下载',
              type: 'text',
              clickFun: (row) => {
                this.download(row);
@@ -887,7 +887,7 @@
              },
            },
            {
              name: '报告下载',
              name: '报告预览/下载',
              type: 'text',
              clickFun: (row) => {
                this.download(row);
src/views/business/materialOrderComponents/materialOrder/downFileDialog.vue
@@ -1,85 +1,58 @@
<template>
  <div>
    <el-dialog title="报告查看下载" v-model="isShow" width="500px" @closed="$emit('closeDownFileDialog')">
      <div style="margin-bottom: 18px">
        <span style="font-size: 16px;">进厂检验报告下载</span>
        <lims-icon name="el-icon-paperclip" style="color:var(--el-color-primary);margin-left: 6px"></lims-icon>
        <el-link :underline="false" style="vertical-align: bottom;font-size: 16px;"
                 :disabled="!downLoadInfo.enterUrlS && !downLoadInfo.enterUrl"
                 type="primary" @click="downLoad0">查看</el-link>
      </div>
      <div>
        <span  style="font-size: 16px;">季度检验报告下载</span>
        <lims-icon name="el-icon-paperclip" style="color:var(--el-color-primary);margin-left: 6px"></lims-icon>
        <el-link :underline="false" style="vertical-align: bottom;font-size: 16px;"
                 :disabled="!downLoadInfo.quarterUrlS && !downLoadInfo.quarterUrl"
                 type="primary" @click="downLoad1">查看</el-link>
    <el-dialog title="报告预览/下载" v-model="isShow" width="520px" @closed="$emit('closeDownFileDialog')">
      <div v-for="item in reports" :key="item.key" class="report-choice">
        <span>{{ item.label }}</span>
        <el-button link type="primary" :disabled="!canView(item)" @click="preview(item)">预览</el-button>
        <el-button link type="primary" :disabled="!canView(item)" @click="download(item)">下载</el-button>
        <span v-if="!canView(item)" class="report-unavailable">未生成或尚未批准</span>
      </div>
    </el-dialog>
    <report-preview ref="reportPreview" />
  </div>
</template>
<script>
import ReportPreview from '@/components/ReportPreview/index.vue'
import { loadReportFile, reportFilename, reportPdfPath } from '@/utils/reportPreview'
export default {
  name: "downFileDialog",
  // import å¼•入的组件需要注入到对象中才能使用
  components: {},
  name: 'downFileDialog',
  components: { ReportPreview },
  props: {
    downFileDialogVisible: {
      type: Boolean,
      default: () => false
    },
    downLoadInfo: {
      type: Object,
      default: () => {}
    },
    downFileDialogVisible: { type: Boolean, default: false },
    downLoadInfo: { type: Object, default: () => ({}) }
  },
  data() {
    // è¿™é‡Œå­˜æ”¾æ•°æ®
    return {
      isShow: this.downFileDialogVisible,
    return { isShow: this.downFileDialogVisible }
  },
  computed: {
    reports() {
      const info = this.downLoadInfo
      return [
        { key: 'enter', label: '进厂检验报告', url: info.enterUrlS || info.enterUrl, status: info.enterRatifyStatus },
        { key: 'quarter', label: '季度检验报告', url: info.quarterUrlS || info.quarterUrl, status: info.quarterRatifyStatus }
      ]
    }
  },
  // æ–¹æ³•集合
  methods: {
    // è¿›åŽ‚æ£€éªŒæŠ¥å‘Šä¸‹è½½
    downLoad0 () {
      if(this.downLoadInfo.enterRatifyStatus !== 1){
        this.$message.error("报告未审批或审批未通过,无法下载")
        return
      }
      let url = this.downLoadInfo.enterUrlS ? this.downLoadInfo.enterUrlS : this.downLoadInfo.enterUrl
      if(url){
        url = url.split('.')[0]+'.pdf'
        const link = document.createElement('a');
        link.href = this.javaApi + url;
        link.target = '_blank';
        document.body.appendChild(link);
        link.click();
      }
    canView(item) { return !!item.url && Number(item.status) === 1 },
    preview(item) {
      if (!this.canView(item)) return
      this.$refs.reportPreview.open([{
        key: item.key, label: item.label, format: 'pdf',
        filename: reportFilename(item.label, 'pdf'),
        load: signal => loadReportFile(reportPdfPath(item.url), signal)
      }])
    },
    // å­£åº¦æ£€éªŒæŠ¥å‘Šä¸‹è½½
    downLoad1 () {
      if(this.downLoadInfo.quarterRatifyStatus !== 1){
        this.$message.error("报告未审批或审批未通过,无法下载")
        return
      }
      let url = this.downLoadInfo.quarterUrlS ? this.downLoadInfo.quarterUrlS : this.downLoadInfo.quarterUrl
      if(url){
        url = url.split('.')[0]+'.pdf'
        const link = document.createElement('a');
        link.href = this.javaApi + url;
        link.target = '_blank';
        document.body.appendChild(link);
        link.click();
      }
    download(item) {
      if (this.canView(item)) this.$download.url(reportPdfPath(item.url), item.label + '.pdf')
    }
  },
  }
}
</script>
<style scoped>
:deep(.el-dialog) {
  height: 160px;
}
.report-choice { display: flex; align-items: center; gap: 12px; padding: 12px 0; flex-wrap: wrap; }
.report-unavailable { color: var(--el-text-color-secondary); font-size: 12px; }
</style>
src/views/business/productOrder/index.vue
@@ -265,10 +265,13 @@
        <el-button :loading="submitListLoad" type="primary" @click="submitList">ç¡® å®š</el-button>
      </span></template>
    </el-dialog>
    <report-preview ref="reportPreview" />
  </div>
</template>
<script>
import ReportPreview from '@/components/ReportPreview/index.vue'
import { loadReportFile, reportFilename, reportPdfPath } from '@/utils/reportPreview'
// vue-qr åªæ”¯æŒ Vue2,换成同 props çš„æœ¬åœ°ç»„件(内部用 Vue3 çš„ qrcode.vue)
import vueQr from '@/components/QrCode'
import PrintJS from 'print-js'
@@ -290,6 +293,7 @@
  components: {
    limsTable,
    PrintDialog,
    ReportPreview,
    vueQr
  },
  data() {
@@ -373,6 +377,13 @@
              showHide: (row) => {
                return this.tabIndex === 1 || this.tabIndex === 2 || this.tabIndex === 3 || this.tabIndex === 6 || this.tabIndex === 7
              }
            },
            {
              name: '报告预览',
              type: 'text',
              clickFun: (row) => this.previewReport(row),
              disabled: (row) => !this.canDownloadReport(row),
              showHide: () => [2, 3, 7].includes(this.tabIndex)
            },
            {
              name: '报告下载',
@@ -882,12 +893,21 @@
    canDownloadReport(row) {
      return !!(row && row.reportId && (row.urlS || row.url))
    },
    previewReport(row) {
      if (!this.canDownloadReport(row)) return
      const path = reportPdfPath(row.urlS || row.url)
      this.$refs.reportPreview.open([{
        key: 'approved', label: '有章报告', format: 'pdf',
        filename: reportFilename(row.reportCode || row.entrustCode || row.code, 'pdf'),
        load: signal => loadReportFile(path, signal)
      }])
    },
    download(row) {
      if (!this.canDownloadReport(row)) {
        return
      }
      let url = row.urlS ? row.urlS : row.url;
      url = url.split('.')[0] + '.pdf'
      url = reportPdfPath(url)
      const link = document.createElement('a');
      link.href = this.javaApi + url;
      link.target = '_blank';
src/views/business/reportPreparation/index.vue
@@ -48,7 +48,8 @@
                  :isSelection="true" :handleSelectionChange="handleChange" :height="'calc(100vh - 290px)'"
                  @pagination="pagination" key="valueTableData">
        <template #action="scope"><div>
          <!--          <el-button size="small" type="text" @click="viewIssued(scope.row)">查看报告</el-button>-->
          <el-button link type="primary" size="small" :disabled="!scope.row.url && !scope.row.urlS"
                     @click="previewReport(scope.row)">预览</el-button>
          <el-upload ref='upload1' style="display: inline;margin: 0 6px" :action="fileAction1 + '?id=' + scope.row.id"
                     :auto-upload="true" :before-upload="fileBeforeUpload1" :headers="uploadHeader" :on-error="onError1"
                     :on-success="handleSuccessUp1" :show-file-list="false" accept='.doc,.docx'>
@@ -59,7 +60,8 @@
          <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 v-if="scope.row.state == 1" link type="primary" size="small"
                     @click="openSendBack(scope.row)">退回任务</el-button>
          <el-button link type="primary" size="small" :disabled="scope.row.state != 0 || nickName !== scope.row.writeUserName"
                     @click="handle(scope.row)">提交
          </el-button>
@@ -68,7 +70,7 @@
                     @click="handleIssued(scope.row)">审核
          </el-button>
          <el-button link type="primary" size="small"
                     :disabled="scope.row.state == null || scope.row.state == 0 || scope.row.isExamine == 0 || scope.row.isExamine == null || scope.row.isRatify == 1 || nickName !== scope.row.ratifyUser"
                     :disabled="scope.row.state == null || scope.row.state == 0 || scope.row.isExamine != 1 || scope.row.isRatify == 1 || nickName !== scope.row.ratifyUser"
                     @click="handleApprove(scope.row)">批准
          </el-button>
          <el-button style="margin-left: 10px" link type="primary" size="small"
@@ -84,6 +86,25 @@
        </div></template>
      </lims-table>
    </div>
    <el-dialog v-model="sendBackVisible" title="退回任务" width="460px" :close-on-click-modal="false">
      <el-form label-width="90px">
        <el-form-item label="退回位置">
          <el-radio-group v-if="sendBackRow && sendBackRow.isRatify == 1" v-model="sendBackForm.target">
            <el-radio value="INSPECTION_TASK">退回到检验任务</el-radio>
            <el-radio value="REPORT_REVIEW">退回到报告审核</el-radio>
          </el-radio-group>
          <span v-else>退回到检验任务</span>
        </el-form-item>
        <el-form-item label="退回原因">
          <el-input v-model="sendBackForm.reason" type="textarea" :rows="4" maxlength="1000"
                    show-word-limit placeholder="请输入退回原因" />
        </el-form-item>
      </el-form>
      <template #footer><span class="dialog-footer">
        <el-button :disabled="sendBackLoading" @click="sendBackVisible = false">取消</el-button>
        <el-button type="primary" :loading="sendBackLoading" @click="submitSendBack">确定退回</el-button>
      </span></template>
    </el-dialog>
    <el-dialog :fullscreen="fullscreen" :modal-append-to-body="false" v-model="claimVisible" title="在线编制"
               width="22cm">
      <div class="full-screen">
@@ -160,7 +181,7 @@
        <img v-else alt="" src="@/assets/images/no-full.svg" style="cursor: pointer;" @click="fullscreen = false;">
      </div>
      <div v-if="approveVisible" class="approve-preview">
        <onlyoffice ref="onlyoffice" :options="option" style="width: 100%;height: 100%;"/>
        <pdf-viewer :key="currentInfo.id" :load="loadApprovalPdf" />
      </div>
      <div class="seal-select-block" v-loading="sealLoading">
        <div class="seal-select-title">
@@ -247,18 +268,7 @@
    </el-dialog>
    <!--产业链信息查看-->
    <!--    <ShowInfo v-if="showInfoDialog" ref="showInfoDialog" :showInfoDialog="showInfoDialog"></ShowInfo>-->
    <!--报告查看-->
    <el-dialog :fullscreen="fullscreen" :modal-append-to-body="false" v-model="viewIssuedVisible" title="报告查看"
               width="80vw">
      <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="viewIssuedVisible" style="height: 80vh;">
        <onlyoffice ref="onlyoffice" :options="option" style="width: 100%;height: 100%;"/>
      </div>
    </el-dialog>
    <report-preview ref="reportPreview" />
    <!--附件查看-->
    <el-dialog v-model="filesDialogVisible" title="附件查看" width="80%" @closed="closeFilesLook">
      <div style="margin-bottom: 10px">
@@ -285,6 +295,7 @@
      </el-form>
      <template #footer><span  class="dialog-footer">
        <el-button @click="downloadDialogVisible = false">取 æ¶ˆ</el-button>
        <el-button :disabled="!downloadRow" @click="previewReport(downloadRow, selectedReportType)">预 è§ˆ</el-button>
        <el-button type="primary" :loading="downloadingReportType !== null"
                   :disabled="selectedReportType === null || downloadingReportType !== null"
                   @click="downloadSelectedReport(selectedReportType)">下 è½½</el-button>
@@ -294,12 +305,15 @@
</template>
<script>
import onlyoffice from "@/components/Onlyoffice/onlyoffice.vue";
import PdfViewer from '@/components/ReportPreview/PdfViewer.vue'
import ReportWordEditor from "@/components/ReportWordEditor/index.vue";
import ReportPreview from '@/components/ReportPreview/index.vue'
import { reportFilename } from '@/utils/reportPreview'
// import ShowInfo from "../do/b1-material-ins-order/showInfo.vue";
import {
  downAll,
  downloadReport,
  previewReportPdf,
  examineReport,
  getReportEditDocument,
  saveReportEditDocument,
@@ -321,10 +335,11 @@
export default {
  name: 'ReportPreparation',
  components: {limsTable, onlyoffice, ReportWordEditor},
  components: {limsTable, PdfViewer, ReportWordEditor, ReportPreview},
  dicts: ['material_prop_type'],
  data() {
    return {
      hasActivated: false,
      entity: {
        queryStatus: null,
        code: null,
@@ -345,6 +360,13 @@
      issuedReasonVisible: false,
      approveVisible: false,
      approveReasonVisible: false,
      sendBackVisible: false,
      sendBackLoading: false,
      sendBackRow: null,
      sendBackForm: {
        target: null,
        reason: ''
      },
      sealOptions: [],
      sealPositions: [
        { key: 'maSealId', label: 'MA标志章', description: 'PDF ç¬¬1页左上角' },
@@ -418,11 +440,11 @@
      customsInspection: {},
      currentId: null,
      examine: null,
      viewIssuedVisible: false,
      queryStatusList: [
        {label: '待提交', value: 0},
        {label: '待审核', value: 1},
        {label: '待批准', value: 2},
        {label: '已退回', value: 3},
      ],
      state: 0,
      orderId: 0,
@@ -580,6 +602,8 @@
              return "不通过";
            } else if (params == 1) {
              return "通过";
            } else if (params == 2) {
              return "已退回";
            } else {
              return null
            }
@@ -589,6 +613,8 @@
              return "danger";
            } else if (params == 1) {
              return "success";
            } else if (params == 2) {
              return "warning";
            } else {
              return undefined
            }
@@ -607,6 +633,8 @@
              return "不批准";
            } else if (params == 1) {
              return "批准";
            } else if (params == 2) {
              return "已退回";
            } else {
              return null
            }
@@ -616,6 +644,8 @@
              return "danger";
            } else if (params == 1) {
              return "success";
            } else if (params == 2) {
              return "warning";
            } else {
              return undefined
            }
@@ -647,6 +677,13 @@
  },
  mounted() {
    this.refreshTable()
  },
  activated() {
    // é¦–次进入由 mounted åŠ è½½ï¼Œç¼“å­˜é¡µé¢å†æ¬¡æ¿€æ´»æ—¶åˆ·æ–°åˆ—è¡¨ã€‚
    if (this.hasActivated) {
      this.refreshTable()
    }
    this.hasActivated = true
  },
  methods: {
    refreshTable() {
@@ -1103,52 +1140,47 @@
      this.auditDocument = null
      done()
    },
    // æŸ¥çœ‹æŠ¥å‘Š
    viewIssued(row) {
      console.log('this.javaApi', this.javaApi)
      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"
      }
      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
      }
      this.viewIssuedVisible = true;
    // ä½¿ç”¨æœåŠ¡ç«¯æŒ‰ Word ç‰ˆå¼ç”Ÿæˆçš„ PDF,复杂表格不在浏览器中重新排版。
    previewReport(row, reportType = 0) {
      const variants = [{ key: 0, label: '检测报告' }]
      if (row.inspectionUrl) variants.push({ key: 1, label: '检验报告' })
      this.$refs.reportPreview.open(variants.map(item => ({
        ...item, format: 'pdf',
        filename: reportFilename((row.code || '报告') + (row.inspectionUrl ? '-' + item.label : ''), 'docx'),
        load: signal => previewReportPdf(row.id, item.key, signal),
        download: signal => downloadReport(row.id, item.key, signal), downloadFormat: 'docx'
      })), reportType)
    },
    // é€€å›žåˆ°ä»»åŠ¡
    sendBackTask(row) {
      this.$confirm('确认退回到检验任务?退回后需重新提交复核', '提示', {
        confirmButtonText: '确定',
        cancelButtonText: '取消',
        type: 'warning'
    openSendBack(row) {
      this.sendBackRow = row
      this.sendBackForm = {
        target: row.isRatify == 1 ? null : 'INSPECTION_TASK',
        reason: ''
      }
      this.sendBackVisible = true
    },
    submitSendBack() {
      if (!this.sendBackForm.target) {
        return this.$message.error('请选择退回位置')
      }
      if (!this.sendBackForm.reason || !this.sendBackForm.reason.trim()) {
        return this.$message.error('请输入退回原因')
      }
      if (this.sendBackLoading || !this.sendBackRow) return
      this.sendBackLoading = true
      const targetName = this.sendBackForm.target === 'REPORT_REVIEW' ? '报告审核' : '检验任务'
      sendBackTask({
        id: this.sendBackRow.id,
        target: this.sendBackForm.target,
        reason: this.sendBackForm.reason.trim()
      }).then(() => {
        this.upLoad = true
        sendBackTask({id: row.id}).then(res => {
          this.upLoad = false
          this.$message.success('退回成功!')
          this.refreshTable('page')
        }).catch(err => {
          this.upLoad = false
        })
      }).catch(() => {
        this.$message({
          type: 'info',
          message: '已取消删除'
        });
      });
        this.$message.success(`已退回到${targetName}`)
        this.sendBackVisible = false
        this.sendBackRow = null
        this.refreshTable('page')
      }).finally(() => {
        this.sendBackLoading = false
      })
    },
    // å®¡æ ¸é€šè¿‡
    async submitAddApprover() {
@@ -1228,26 +1260,11 @@
    handleApprove(row) {
      this.currentInfo = row;
      this.loadEnabledSeals(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"
      }
      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
      }
      this.fullscreen = false;
      this.approveVisible = true;
    },
    loadApprovalPdf(signal) {
      return previewReportPdf(this.currentInfo.id, 0, signal)
    },
    // æ‰¹å‡†é€šè¿‡
    subApprove() {
@@ -1368,7 +1385,7 @@
        ratifyTell: this.reason
      }).then(res => {
        this.loadingApproveReason = false
        this.$message.success('操作成功')
        this.$message.success('已退回报告审核')
        this.refreshTable('page')
        this.currentInfo = null;
        this.reason = '';
tests/attachment-download.test.mjs
@@ -149,7 +149,7 @@
    .map(n => n.type === 'ImportDeclaration' ? { start: n.start, end: n.end, text: '' }
      : { start: n.start, end: n.declaration.start, text: 'globalThis.component = ' })
  for (const edit of edits.sort((a, b) => b.start - a.start)) source = source.slice(0, edit.start) + edit.text + source.slice(edit.end)
  const context = vm.createContext({ vueQr: {}, PrintDialog: {}, limsTable: {}, mapGetters: () => ({}), getFileList })
  const context = vm.createContext({ vueQr: {}, PrintDialog: {}, ReportPreview: {}, limsTable: {}, mapGetters: () => ({}), getFileList })
  vm.runInContext(source, context)
  return { ...context.component.data(), ...context.component.methods }
}
vite.config.js
@@ -13,6 +13,9 @@
    // é»˜è®¤æƒ…况下,vite ä¼šå‡è®¾ä½ çš„应用是被部署在一个域名的根路径上
    // ä¾‹å¦‚ https://www.ruoyi.vip/。如果应用被部署在一个子路径上,你就需要用这个选项指定这个子路径。例如,如果你的应用被部署在 https://www.ruoyi.vip/admin/,则设置 baseUrl ä¸º /admin/。
    base: VITE_APP_ENV === 'production' ? '/' : '/',
    // ä¸Žä¸´æ—¶é¢„览、校验脚本使用的默认 .vite/deps ç¼“存隔离,避免运行中的
    // æœåŠ¡ç»§ç»­å¼•ç”¨å·²è¢«å¦ä¸€å®žä¾‹æ›¿æ¢çš„é¢„æž„å»ºä¾èµ–ï¼ˆ504 Outdated Optimize Dep)。
    cacheDir: `node_modules/.vite/lims-${command}-${mode}`,
    plugins: createVitePlugins(env, command === 'build'),
    resolve: {
      // https://cn.vitejs.dev/config/#resolve-alias