吴羡
4 天以前 89fd9ab312c0aef9fcab89fbbaee4add5baa7c57
Merge remote-tracking branch 'origin/master'
已添加2个文件
已修改21个文件
482 ■■■■■ 文件已修改
src/components/Attachment/lims-attachment.vue 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/components/FileUpload/index.vue 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/plugins/download.js 48 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/utils/attachment.js 69 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/utils/reportDocxLayout.js 16 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/CNAS/personnel/personnelInfo/components/ViewRecord.vue 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/CNAS/personnel/personnelInfo/tabs/personnel-information.vue 27 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/CNAS/process/ensureResults/ensureResultsValidity/components/ViewRecord.vue 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/CNAS/process/method/methodVerification/component/ViewTestRecord.vue 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/CNAS/process/method/methodVerification/component/calibrationsFileDia.vue 14 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/CNAS/process/method/methodVerification/component/viewWorkPermitDia.vue 14 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/CNAS/resourceDemand/device/component/check.vue 13 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/CNAS/resourceDemand/device/component/checkRecord.vue 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/CNAS/resourceDemand/device/component/equipmentVerificationPlan.vue 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/CNAS/resourceDemand/device/component/operationInstruction.vue 15 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/business/inspectionTask/components/InspectionWord.vue 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/business/inspectionTask/inspection.vue 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/business/inspectionView/index.vue 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/business/materialOrderComponents/materialOrder/filesLookVisible.vue 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/business/productOrder/index.vue 23 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/business/reportPreparation/index.vue 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/business/unpass/components/unPassDialog.vue 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tests/attachment-download.test.mjs 172 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/components/Attachment/lims-attachment.vue
@@ -60,6 +60,7 @@
<script>
import request from '@/utils/request'
import { resolveAttachmentUrl } from '@/utils/attachment'
import filePreview from '@/components/Preview/filePreview.vue'
/**
@@ -298,7 +299,7 @@
      this.previewVisible = true
    },
    downloadFile(row) {
      this.$download.saveAs(this.fullUrl(row.fileUrl), row.fileName)
      return this.$download.attachment(row, row.fileName)
    },
    removeFile(row) {
      this.$confirm('是否删除该附件?', '提示', { type: 'warning' }).then(() => {
@@ -314,8 +315,7 @@
    },
    // é™æ€åœ°å€æ‹¼æŽ¥ï¼Œä¸ŽåŽç«¯ä¸Šä¼ ç»Ÿä¸€è·¯ç”±ä¿æŒä¸€è‡´ï¼šå›¾ç‰‡åœ¨ /img,其它都在 /word
    fullUrl(fileUrl) {
      const base = /\.(jpg|jpeg|png|gif)$/i.test(fileUrl) ? '/img/' : '/word/'
      return this.javaApi + base + fileUrl
      return resolveAttachmentUrl(fileUrl)
    }
  }
}
src/components/FileUpload/index.vue
@@ -29,7 +29,7 @@
    <!-- æ–‡ä»¶åˆ—表 -->
    <transition-group ref="uploadFileList" class="upload-file-list el-upload-list el-upload-list--text" name="el-fade-in-linear" tag="ul">
      <li :key="file.uid" class="el-upload-list__item ele-upload-list__item-content" v-for="(file, index) in fileList">
        <el-link :href="`${baseUrl}${file.url}`" underline="never" target="_blank">
        <el-link @click="proxy.$download.saveAs(file.url, getFileName(file.name))" underline="never">
          <lims-icon name="el-icon-document" /> {{ getFileName(file.name) }}
        </el-link>
        <div class="ele-upload-list__item-content-action">
@@ -91,7 +91,6 @@
const emit = defineEmits()
const number = ref(0)
const uploadList = ref([])
const baseUrl = import.meta.env.VITE_APP_BASE_API
const uploadFileUrl = ref(import.meta.env.VITE_APP_BASE_API + props.action) // ä¸Šä¼ æ–‡ä»¶æœåŠ¡å™¨åœ°å€
const headers = ref({ Authorization: "Bearer " + getToken() })
const fileList = ref([])
src/plugins/download.js
@@ -4,6 +4,8 @@
import { getToken } from '@/utils/auth'
import errorCode from '@/utils/errorCode'
import { blobValidate } from '@/utils/ruoyi'
import { getJavaApi } from '@/utils/java-api'
import { resolveAttachmentUrl, attachmentFileName, alternateImageAttachmentUrl, validateAttachmentBlob } from '@/utils/attachment'
const baseURL = import.meta.env.VITE_APP_BASE_API
let downloadLoadingInstance
@@ -67,7 +69,51 @@
    })
  },
  saveAs(text, name, opts) {
    saveAs(text, name, opts)
    if (typeof text === 'string') return this.url(text, name, opts)
    return saveAs(text, name, opts)
  },
  attachment(file, name) {
    try {
      return this.url(resolveAttachmentUrl(file?.fileUrl, file?.type), name || file?.fileName, undefined,
        ![1, 2].includes(Number(file?.type)))
    } catch (error) {
      ElMessage.error(error.message)
      return Promise.resolve(false)
    }
  },
  async url(path, name, opts, allowAlternative = true) {
    try {
      const url = resolveAttachmentUrl(path)
      const backend = new URL(getJavaApi(), window.location.origin)
      const target = new URL(url, window.location.origin)
      const isBackend = target.origin === backend.origin
        && (target.pathname === backend.pathname || target.pathname.startsWith(backend.pathname.replace(/\/$/, '') + '/'))
      const filename = attachmentFileName(url, name)
      // å¤–部附件沿用浏览器下载,兼容不允许跨域读取的文件服务。
      if (!isBackend && /^https?:$/.test(target.protocol)) {
        saveAs(url, filename, opts)
        return true
      }
      const headers = isBackend && getToken() ? { Authorization: 'Bearer ' + getToken() } : {}
      let response
      try {
        response = await axios.get(url, { responseType: 'blob', headers })
      } catch (error) {
        const alternative = isBackend && allowAlternative && error.response?.status === 404
          ? alternateImageAttachmentUrl(url) : null
        if (!alternative) throw error
        response = await axios.get(alternative, { responseType: 'blob', headers })
      }
      const { data } = response
      await validateAttachmentBlob(data, filename)
      saveAs(data, filename, opts)
      return true
    } catch (error) {
      const status = error.response?.status
      ElMessage.error(status === 404 ? '附件不存在或已被删除' : status === 401 || status === 403
        ? '登录已过期或没有附件下载权限' : error.message || '附件下载失败,请稍后重试')
      return false
    }
  },
  async printErrMsg(data) {
    const resText = await data.text()
src/utils/attachment.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,69 @@
import { getJavaApi } from '@/utils/java-api'
const imageExtension = /\.(jpg|jpeg|png|gif|bmp|webp|svg|ico|tiff?|avif|heic)$/i
function decodePart(value) {
  try {
    return decodeURIComponent(value)
  } catch {
    return value
  }
}
// æ•°æ®åº“可能保存文件名、静态路径或完整地址;只为文件名补充存储目录。
export function resolveAttachmentUrl(value, type, base = getJavaApi()) {
  if (typeof value !== 'string' || !value.trim()) throw new Error('附件地址为空')
  let path = value.replace(/\\/g, '/')
  base = base.replace(/\/+$/, '')
  if (/^(blob:|data:)/i.test(path)) return path
  if (path.startsWith(base + '/')) path = path.slice(base.length)
  else if (/^(https?:)?\/\//i.test(path)) return new URL(path, window.location.origin).href
  else if (/^[a-z][a-z\d+.-]*:/i.test(path)) throw new Error('附件地址格式不正确')
  // å…¼å®¹å·²ç»å¸¦æœ‰ /dev-api ç­‰ä»£ç†å‰ç¼€çš„地址,避免重复拼接。
  const basePath = new URL(base, window.location.origin).pathname.replace(/\/$/, '')
  if (basePath && path.startsWith(basePath + '/')) path = path.slice(basePath.length)
  path = path.replace(/^\/+/, '')
  if (!/^(img|word|profile)\//i.test(path)) {
    const isImage = Number(type) === 1 || (Number(type) !== 2 && imageExtension.test(decodePart(path)))
    path = (isImage ? 'img/' : 'word/') + path
  }
  // é€æ®µç¼–码,保留目录和已有编码,文件名里的 #、?、空格与中文均作为文件名处理。
  const encodedPath = path.split('/').map(part => encodeURIComponent(decodePart(part))).join('/')
  return base + '/' + encodedPath
}
export function attachmentFileName(url, name) {
  const path = new URL(url, window.location.origin).pathname
  const storedName = decodePart(path.split('/').pop() || '')
  const filename = (name || storedName || '附件').replace(/\\/g, '/').split('/').pop()
  const extension = storedName.match(/\.[a-z\d]{1,10}$/i)?.[0] || ''
  return extension && !/\.[a-z\d]{1,10}$/i.test(filename) ? filename + extension : filename
}
// åŽ†å²ä¸Šä¼ æŽ¥å£å¯¹ BMP/WebP ç­‰æ ¼å¼çš„目录判断不一致,404 æ—¶å…¼å®¹å¦ä¸€å­˜å‚¨ç›®å½•。
export function alternateImageAttachmentUrl(url) {
  const parsed = new URL(url, window.location.origin)
  if (!imageExtension.test(decodePart(parsed.pathname)) || !/\/(img|word)\//.test(parsed.pathname)) return null
  parsed.pathname = parsed.pathname.replace(/\/(img|word)\//, (_, directory) => directory === 'img' ? '/word/' : '/img/')
  return parsed.href
}
// ä¸‹è½½å¤±è´¥çš„ JSON/HTML å“åº”不能保存成 Word、PDF ç­‰â€œæŸåçš„附件”。
export async function validateAttachmentBlob(blob, filename) {
  const head = (await blob.slice(0, 512).text()).trim()
  if (head.startsWith('{') && blob.size < 1024 * 1024) {
    let result
    try {
      result = JSON.parse(await blob.text())
    } catch {
      return
    }
    if (result && ((result.code != null && Number(result.code) !== 200) || result.success === false)) {
      throw new Error(result.msg || result.message || '附件下载失败')
    }
  }
  if (/^(<!doctype html|<html)/i.test(head) && !/\.html?$/i.test(filename)) {
    throw new Error('未获取到附件内容,请确认文件存在或重新登录后重试')
  }
}
src/utils/reportDocxLayout.js
@@ -164,6 +164,21 @@
  return changed ? documentXml : null
}
function clearDefaultSequenceUnderlines(xml) {
  return xml.replace(resultTablePattern, table => {
    if (!table.includes('序号') || !/(检测结果|检验结果)/.test(table)) return table
    return table.replace(tableRowPattern, row => row.replace(tableCellPattern, (cell, offset) => {
      if (offset !== row.indexOf('<w:tc')) return cell
      const text = [...cell.matchAll(/<w:t\b(?![^>]*\/>)[^>]*>([\s\S]*?)<\/w:t>/g)]
        .map(match => match[1]).join('').trim()
      if (!/^\d+$/.test(text)) return cell
      // æ—§æŠ¥å‘Šä¸­çš„ <w:u w:color="auto"/> é»˜è®¤ä¼šè¢«è§£é‡Šæˆå•下划线。
      // æ˜¾å¼è®¾ç½®äº†ç±»åž‹çš„下划线保留,允许用户自行修改序号格式。
      return cell.replace(/<w:u\b[^>]*\/>/g, tag => /\bw:val=/.test(tag) ? tag : '')
    }))
  })
}
export async function normalizeReportDocxForEditor(blob, reportCode) {
  const zip = await JSZip.loadAsync(blob)
  const documentPart = zip.file('word/document.xml')
@@ -173,6 +188,7 @@
  const themeXml = themePart ? await themePart.async('string') : ''
  const themeFonts = { major: chineseThemeFont(themeXml, 'major'), minor: chineseThemeFont(themeXml, 'minor') }
  let normalizedXml = alignResultTable(documentXml) || documentXml
  normalizedXml = clearDefaultSequenceUnderlines(normalizedXml)
  normalizedXml = fitCoverCustomer(normalizedXml) || normalizedXml
  normalizedXml = materializeChineseThemeFonts(normalizedXml, themeFonts) || normalizedXml
  let changed = normalizedXml !== documentXml
src/views/CNAS/personnel/personnelInfo/components/ViewRecord.vue
@@ -113,17 +113,7 @@
    },
    // ä¸‹è½½
    upload (row) {
      let url = '';
      if(row.type==1){
        url = this.javaApi+'/img/'+row.fileUrl
        file.downloadIamge(url,row.fileName)
      }else{
        url = this.javaApi+'/word/'+row.fileUrl
        const link = document.createElement('a');
        link.href = url;
        link.download = row.fileName;
        link.click();
      }
      return this.$download.attachment(row, row.fileName)
    },
    // åˆ é™¤
    delete (row) {
src/views/CNAS/personnel/personnelInfo/tabs/personnel-information.vue
@@ -470,7 +470,7 @@
</template>
<script>
import fileDownload from '@/utils/file'
import {
  addAnnex,
  addBasicInfoWork,
@@ -734,17 +734,7 @@
    },
    // ä¸‹è½½
    upload(row) {
      let url = '';
      if (row.type == 1) {
        url = this.javaApi + '/img/' + row.fileUrl
        fileDownload.downloadIamge(url, row.fileName)
      } else {
        url = this.javaApi + '/word/' + row.fileUrl
        const link = document.createElement('a');
        link.href = url;
        link.download = row.fileName;
        link.click();
      }
      return this.$download.attachment(row, row.fileName)
    },
    // åˆ é™¤
    delete(row) {
@@ -932,18 +922,7 @@
      }
    },
    downloadFile(fileName) {
      let state = /\.(jpg|jpeg|png|gif)$/i.test(fileName)
      if (state) {
        let url = this.javaApi + '/img/' + fileName;
        fileDownload.downloadIamge(url, fileName)
      } else {
        const url = this.javaApi + '/word/' + fileName
        const link = document.createElement('a');
        link.href = url;
        link.download = fileName;
        link.click();
        this.$message.success('下载成功')
      }
      return this.$download.saveAs(fileName, fileName)
    },
    async onSuccess(response, file, fileList, entityVal) {
      if (entityVal == 'fileName') {
src/views/CNAS/process/ensureResults/ensureResultsValidity/components/ViewRecord.vue
@@ -116,17 +116,7 @@
    },
    // ä¸‹è½½
    upload (row) {
      let url = '';
      if(row.type==1){
        url = this.javaApi+'/img/'+row.fileUrl
        file.downloadIamge(url,row.fileName)
      }else{
        url = this.javaApi+'/word/'+row.fileUrl
        const link = document.createElement('a');
        link.href = url;
        link.download = row.fileName;
        link.click();
      }
      return this.$download.attachment(row, row.fileName)
    },
    // åˆ é™¤
    delete (row) {
src/views/CNAS/process/method/methodVerification/component/ViewTestRecord.vue
@@ -116,17 +116,7 @@
    },
    // ä¸‹è½½
    upload (row) {
      let url = '';
      if(row.type==1){
        url = this.javaApi+'/img/'+row.fileUrl
        file.downloadIamge(url,row.fileName)
      }else{
        url = this.javaApi+'/word/'+row.fileUrl
        const link = document.createElement('a');
        link.href = url;
        link.download = row.fileName;
        link.click();
      }
      return this.$download.attachment(row, row.fileName)
    },
    // åˆ é™¤
    delete (row) {
src/views/CNAS/process/method/methodVerification/component/calibrationsFileDia.vue
@@ -28,7 +28,7 @@
<script>
import filePreview from '@/components/Preview/filePreview.vue';
import limsTable from '@/components/Table/lims-table.vue'
import file from '@/utils/file';
import { getCurrentCalibrationFiles } from '@/api/cnas/process/method/methodVerification'
export default {
@@ -120,17 +120,7 @@
    },
    // ä¸‹è½½
    upload (row) {
      let url = '';
      if (/\.(png|jpe?g|gif|bmp|webp)$/i.test(row.fileUrl || '')) {
        url = this.javaApi+'/img/'+row.fileUrl
        file.downloadIamge(url,row.fileName)
      }else{
        url = this.javaApi+'/word/'+row.fileUrl
        const link = document.createElement('a');
        link.href = url;
        link.download = row.fileName;
        link.click();
      }
      return this.$download.attachment(row, row.fileName)
    },
  }
};
src/views/CNAS/process/method/methodVerification/component/viewWorkPermitDia.vue
@@ -26,7 +26,7 @@
</template>
<script>
import file from '@/utils/file';
import filePreview from '@/components/Preview/filePreview.vue';
import limsTable from '@/components/Table/lims-table.vue'
import {getCurrentWorkFiles} from '@/api/cnas/process/method/methodVerification'
@@ -134,17 +134,7 @@
        this.$message.warning('该人员已有任职授权记录,但尚未上传上岗证书文件')
        return
      }
      let url = '';
      if (/\.(png|jpe?g|gif|bmp|webp)$/i.test(row.fileUrl || '')) {
        url = this.javaApi + '/img/' + row.fileUrl
        file.downloadIamge(url, row.fileName)
      } else {
        url = this.javaApi + '/word/' + row.fileUrl
        const link = document.createElement('a');
        link.href = url;
        link.download = row.fileName;
        link.click();
      }
      return this.$download.attachment({ fileUrl: row.fileUrl }, row.fileName)
    },
  },
};
src/views/CNAS/resourceDemand/device/component/check.vue
@@ -516,10 +516,7 @@
    },
    // end
    handleAttachmentClick(row) {
      // æ¨¡æ‹Ÿä¸‹è½½é™„ä»¶
      const imageUrl = this.javaApi + '/img/' + row.systemFileName; // å›¾ç‰‡ URL
      file.downloadIamge(imageUrl, row.fileName)
      // downloadImage(imageUrl, row.fileName);
      return this.$download.attachment({ fileUrl: row.systemFileName }, row.fileName)
    },
    // æ·»åŠ æ ¸æŸ¥è®°å½•
    add(type) {
@@ -583,14 +580,6 @@
  }
}
function downloadImage(url, name) {
  const link = document.createElement('a');
  link.href = url;
  link.download = name; // æ–‡ä»¶å
  document.body.appendChild(link);
  link.click();
  document.body.removeChild(link);
}
</script>
<style scoped>
.dialog-content {
src/views/CNAS/resourceDemand/device/component/checkRecord.vue
@@ -451,8 +451,7 @@
      if (!this.form.fileUrl) {
        return
      }
      const isImage = /\.(jpg|jpeg|png|gif)$/i.test(this.form.fileUrl)
      window.open(this.javaApi + (isImage ? '/img/' : '/word/') + this.form.fileUrl, '_blank')
      return this.$download.saveAs(this.form.fileUrl, this.form.fileName)
    },
  },
  computed: {
src/views/CNAS/resourceDemand/device/component/equipmentVerificationPlan.vue
@@ -568,8 +568,7 @@
    },
    // æ‰“开附件
    openAttachment(row) {
      const isImage = /\.(jpg|jpeg|png|gif)$/i.test(row.fileUrl)
      window.open(this.javaApi + (isImage ? '/img/' : '/word/') + row.fileUrl, '_blank')
      return this.$download.saveAs(row.fileUrl, row.fileName)
    },
    // åˆ—表附件列只读查看(上传/删除入口只在编辑、新增弹窗内)
    handleAttachment(row) {
src/views/CNAS/resourceDemand/device/component/operationInstruction.vue
@@ -182,7 +182,7 @@
</template>
<script>
import fileDownload from "@/utils/file";
import {
  approvalOfHomeworkInstructionManual,
  homeworkGuidebook,
@@ -322,18 +322,7 @@
      });
    },
    downloadFile(fileName) {
      let state = /\.(jpg|jpeg|png|gif)$/i.test(fileName)
      if (state) {
        let url = this.javaApi + '/img/' + fileName;
        fileDownload.downloadIamge(url, fileName)
      } else {
        const url = this.javaApi + '/word/' + fileName
        const link = document.createElement('a');
        link.href = url;
        link.download = fileName;
        link.click();
        this.$message.success('下载成功')
      }
      return this.$download.saveAs(fileName, fileName)
    },
    instructionEditFun(row) {
      this.dialogVisible = true
src/views/business/inspectionTask/components/InspectionWord.vue
@@ -1729,7 +1729,7 @@
      downFile({
        id: row.id,
      }).then(res => {
        this.$download.saveAs(res.data.fileUrl, row.fileName);
        return this.$download.attachment(res.data, row.fileName);
      }).catch(error => {
      })
src/views/business/inspectionTask/inspection.vue
@@ -2242,9 +2242,7 @@
    },
    handleDown(row) {
      downFile({ id: row.id }).then((res) => {
        const resourcePath = Number(res.data.type) === 1 ? "/img/" : "/word/";
        const fileUrl = this.javaApi + resourcePath + encodeURIComponent(res.data.fileUrl);
        this.$download.saveAs(fileUrl, row.fileName);
        return this.$download.attachment(res.data, row.fileName);
      })
        .catch((error) => { });
    },
src/views/business/inspectionView/index.vue
@@ -2470,9 +2470,7 @@
    },
    handleDown(row) {
      downFile({ id: row.id }).then((res) => {
        const resourcePath = Number(res.data.type) === 1 ? "/img/" : "/word/";
        const fileUrl = this.javaApi + resourcePath + encodeURIComponent(res.data.fileUrl);
        this.$download.saveAs(fileUrl, row.fileName);
        return this.$download.attachment(res.data, row.fileName);
      })
        .catch((error) => { });
    },
src/views/business/materialOrderComponents/materialOrder/filesLookVisible.vue
@@ -124,6 +124,7 @@
    // åˆ‡æ¢æ•°æ®æŸ¥çœ‹tab栏
    handleDataVisibleTab(m, i) {
      this.dataVisibleIndex = i
      this.pageFile.current = 1
      this.getFileList()
    },
    // æŸ¥è¯¢å›žè°ƒ
@@ -134,7 +135,7 @@
        this.entity.insOrderId = this.filesLookInfo.quarterOrderId
      }
      this.tableLoadingFile = true
      const params = { ...this.entity }
      const params = { ...this.entity, current: this.pageFile.current, size: this.pageFile.size }
      getFileList(params).then(res => {
        this.tableLoadingFile = false
        if (res.code === 200) {
@@ -146,13 +147,14 @@
      })
    },
    paginationFile(page) {
      this.pageFile.current = page.page
      this.pageFile.size = page.limit
      this.getFileList()
    },
    // ä¸‹è½½
    handleDown(row) {
      downFile({ id: row.id, }).then(res => {
        this.$download.saveAs(res.data.fileUrl, row.fileName);
        return this.$download.attachment(res.data, row.fileName);
      }).catch(error => {
      })
src/views/business/productOrder/index.vue
@@ -15,10 +15,10 @@
          <el-input v-model="entity.sampleModel" clearable placeholder="请输入" size="small"
            @keyup.enter="refreshTable()"></el-input>
        </el-form-item>
        <el-form-item label="下单时间" prop="createTime" v-if="more">
          <el-date-picker v-model="entity.createTime" clearable format="YYYY-MM-DD" placeholder="选择日期" size="small"
        <el-form-item label="送样时间" prop="sampleDate" v-if="more">
          <el-date-picker v-model="entity.sampleDate" clearable format="YYYY-MM-DD" placeholder="选择日期" size="small"
                          @change="refreshTable"
            style="width:100%" type="date" value-format="YYYY-MM-DD HH:mm:ss">
            style="width:100%" type="date" value-format="YYYY-MM-DD">
          </el-date-picker>
        </el-form-item>
        <el-form-item label="委托人" prop="prepareUser" v-if="more">
@@ -298,6 +298,7 @@
      entity: {
        orderType: '',
        state: '',
        sampleDate: null,
      },
      upIndex: 0,
      tableData: [],
@@ -332,7 +333,7 @@
        { label: '备注', prop: 'remark' },
        { label: '检验进度%', prop: 'insProgress' },
        { label: '约定时间', prop: 'appointed' },
        { label: '下单时间', prop: 'createTime' },
        { label: '送样时间', prop: 'sampleDate', minWidth: 110 },
        { label: '下发时间', prop: 'sendTime' },
        { label: '退回理由', prop: 'tell' },
        { label: '拒收原因', prop: 'sampleRejectRemark' },
@@ -471,6 +472,7 @@
      verifyDialogVisible: false,
      quashDialogVisible: false,
      filesDialogVisible: false, // é™„件查看弹框
      attachmentOrderId: null,
      tableDataFile: [],
      tableLoadingFile: false,
      columnFile: [
@@ -652,6 +654,7 @@
    },
    refresh() {
      this.resetForm('entity')
      this.entity.sampleDate = null
      this.refreshTable()
    },
    pagination(page) {
@@ -842,12 +845,15 @@
    },
    // é™„件查看
    handleFileLook(row) {
      this.attachmentOrderId = row.id
      this.pageFile.current = 1
      this.tableDataFile = []
      this.filesDialogVisible = true
      this.getFileList(row)
      this.getFileList()
    },
    getFileList(row) {
    getFileList() {
      this.tableLoadingFile = true
      getFileList({ insOrderId: row.id }).then(res => {
      getFileList({ insOrderId: this.attachmentOrderId, current: this.pageFile.current, size: this.pageFile.size }).then(res => {
        this.tableLoadingFile = false
        if (res.code === 200) {
          this.tableDataFile = res.data.records
@@ -858,6 +864,7 @@
      })
    },
    paginationFile(page) {
      this.pageFile.current = page.page
      this.pageFile.size = page.limit
      this.getFileList()
    },
@@ -866,7 +873,7 @@
      downFile({
        id: row.id,
      }).then(res => {
        this.$download.saveAs(res.data.fileUrl, row.fileName);
        return this.$download.attachment(res.data, row.fileName);
      }).catch(error => {
      })
src/views/business/reportPreparation/index.vue
@@ -728,7 +728,7 @@
      downFile({
        id: row.id,
      }).then(res => {
        this.$download.saveAs(res.data.fileUrl, row.fileName);
        return this.$download.attachment(res.data, row.fileName);
      }).catch(error => {
      })
src/views/business/unpass/components/unPassDialog.vue
@@ -180,7 +180,7 @@
      downFile({
        id: row.id,
      }).then(res => {
        this.$download.saveAs(res.data.fileUrl, row.fileName);
        return this.$download.attachment(res.data, row.fileName);
      }).catch(error => {
      })
tests/attachment-download.test.mjs
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,172 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import fs from 'node:fs'
import vm from 'node:vm'
import { parse as parseVue } from '@vue/compiler-sfc'
import { parse as parseJs } from 'acorn'
const backend = 'https://lims.example/dev-api'
const read = path => fs.readFileSync(new URL(path, import.meta.url), 'utf8')
function withoutImports(source) {
  const imports = parseJs(source, { sourceType: 'module', ecmaVersion: 'latest' }).body
    .filter(node => node.type === 'ImportDeclaration')
  for (const node of imports.reverse()) source = source.slice(0, node.start) + source.slice(node.end)
  return source
}
function downloader() {
  const saved = [], requests = [], errors = []
  const state = { data: new Blob(['original bytes']), error: null, missingUrls: [] }
  const context = vm.createContext({
    URL, Blob, window: { location: { origin: 'https://lims.example' } },
    getJavaApi: () => backend, getToken: () => 'test-token',
    saveAs: (...args) => saved.push(args),
    ElMessage: { error: message => errors.push(message) },
    axios: { get: async (url, options) => {
      requests.push({ url, ...options })
      if (state.missingUrls.includes(url)) throw { response: { status: 404 } }
      if (state.error) throw state.error
      return { data: state.data }
    } },
  })
  const helper = withoutImports(read('../src/utils/attachment.js')).replace(/^export /mg, '')
  vm.runInContext(helper, context)
  const plugin = withoutImports(read('../src/plugins/download.js'))
    .replace('import.meta.env.VITE_APP_BASE_API', "'/dev-api'")
    .replace('export default', 'globalThis.download =')
  vm.runInContext(plugin, context)
  return { context, saved, requests, errors, state, download: context.download }
}
test('文件名补充文档/图片目录,并优先遵循附件的存储类型', () => {
  const { context: c } = downloader()
  for (const [file, type, directory] of [
    ['report.docx', undefined, 'word'], ['report.pdf', 2, 'word'],
    ['photo.PNG', undefined, 'img'], ['photo.webp', undefined, 'img'],
    ['scan.bin', 1, 'img'], ['photo.png', 2, 'word'], ['photo.bmp', 0, 'img'],
  ]) assert.equal(c.resolveAttachmentUrl(file, type), `${backend}/${directory}/${file}`)
})
test('已有静态路径与代理前缀不会重复拼接', () => {
  const { context: c } = downloader()
  for (const path of ['word/a.docx', '/word/a.docx', '/dev-api/word/a.docx', `${backend}/word/a.docx`]) {
    assert.equal(c.resolveAttachmentUrl(path), `${backend}/word/a.docx`)
  }
  assert.equal(c.resolveAttachmentUrl('/profile/upload/2026/a.pdf'), `${backend}/profile/upload/2026/a.pdf`)
  assert.equal(c.resolveAttachmentUrl('word\\folder\\a.pdf'), `${backend}/word/folder/a.pdf`)
})
test('中文、空格、#、?、百分号和已有编码均按真实文件名下载', () => {
  const { context: c } = downloader()
  for (const name of ['委托附件 #1?.docx', '浓度100%.pdf', 'a+b&c.pdf']) {
    const expected = `${backend}/word/${encodeURIComponent(name)}`
    assert.equal(c.resolveAttachmentUrl(name), expected)
    assert.equal(c.resolveAttachmentUrl(expected), expected)
  }
  assert.equal(c.resolveAttachmentUrl('/word/folder/a%20b.pdf'), `${backend}/word/folder/a%20b.pdf`)
})
test('保留外部地址查询参数,拒绝空地址及无效协议', () => {
  const { context: c } = downloader()
  const external = 'https://files.example/a.pdf?signature=abc#page=1'
  assert.equal(c.resolveAttachmentUrl(external), external)
  for (const value of [null, '', ' ', 'javascript:alert(1)']) assert.throws(() => c.resolveAttachmentUrl(value))
})
test('下载名保留原始扩展名,无名称时使用存储文件名', () => {
  const { context: c } = downloader()
  assert.equal(c.attachmentFileName(`${backend}/word/test.pdf`, '检验附件'), '检验附件.pdf')
  assert.equal(c.attachmentFileName(`${backend}/word/test.docx`, '原始记录.docx'), '原始记录.docx')
  assert.equal(c.attachmentFileName(`${backend}/word/a%20b.pdf`), 'a b.pdf')
})
test('图片和文档下载原始 Blob,并仅向业务服务器发送登录凭据', async () => {
  const { download, saved, requests, state } = downloader()
  assert.equal(await download.attachment({ fileUrl: '原图.jpg', type: 1 }, '原图.jpg'), true)
  assert.equal(saved[0][0], state.data)
  assert.equal(saved[0][1], '原图.jpg')
  assert.equal(requests[0].url, `${backend}/img/${encodeURIComponent('原图.jpg')}`)
  assert.equal(requests[0].responseType, 'blob')
  assert.equal(requests[0].headers.Authorization, 'Bearer test-token')
  await download.saveAs('https://files.example/a.docx?signature=abc', '附件.docx')
  assert.equal(requests.length, 1)
  assert.equal(saved[1][0], 'https://files.example/a.docx?signature=abc')
})
test('接口错误与错误网页不保存成附件,合法 JSON/HTML é™„件可正常下载', async () => {
  const { download, saved, errors, state } = downloader()
  state.data = new Blob(['{"code":500,"msg":"附件不存在"}'], { type: 'application/json' })
  assert.equal(await download.saveAs('missing.docx', 'missing.docx'), false)
  assert.equal(errors.pop(), '附件不存在')
  state.data = new Blob(['<!doctype html><html>Not found</html>'], { type: 'text/html' })
  assert.equal(await download.saveAs('missing.pdf'), false)
  assert.equal(saved.length, 0)
  state.data = new Blob(['{"hello":"world"}'], { type: 'application/json' })
  assert.equal(await download.saveAs('data.json'), true)
  state.data = new Blob(['<!doctype html><html>Document</html>'], { type: 'text/html' })
  assert.equal(await download.saveAs('document.html'), true)
})
test('404、登录失效及空文件地址提示失败,不生成下载文件', async () => {
  const { download, saved, errors, requests, state } = downloader()
  for (const status of [404, 401, 403]) {
    state.error = { response: { status } }
    assert.equal(await download.saveAs('a.pdf'), false)
  }
  const count = requests.length
  assert.equal(await download.attachment({ fileUrl: null }), false)
  assert.equal(requests.length, count)
  assert.equal(saved.length, 0)
  assert.equal(errors.length, 4)
})
test('现有导出接口传入 Blob æ—¶ç›´æŽ¥ä¿å­˜ï¼Œä¸é¢å¤–发起请求', () => {
  const { download, saved, requests } = downloader()
  const data = new Blob(['exported bytes'])
  download.saveAs(data, '导出.xlsx')
  assert.equal(saved[0][0], data)
  assert.equal(saved[0][1], '导出.xlsx')
  assert.equal(requests.length, 0)
})
test('兼容历史图片保存目录差异,明确指定存储类型的附件不猜测其他目录', async () => {
  const { download, saved, requests, state } = downloader()
  state.missingUrls = [`${backend}/img/scan.bmp`]
  assert.equal(await download.saveAs('scan.bmp', '原扫描件.bmp'), true)
  assert.equal(requests[1].url, `${backend}/word/scan.bmp`)
  assert.equal(saved[0][1], '原扫描件.bmp')
  const count = requests.length
  assert.equal(await download.attachment({ fileUrl: 'scan.bmp', type: 1 }), false)
  assert.equal(requests.length, count + 1)
})
function orderComponent(getFileList) {
  const { descriptor } = parseVue(read('../src/views/business/productOrder/index.vue'))
  let source = descriptor.script.content
  const ast = parseJs(source, { sourceType: 'module', ecmaVersion: 'latest' })
  const edits = ast.body.filter(n => n.type === 'ImportDeclaration' || n.type === 'ExportDefaultDeclaration')
    .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 })
  vm.runInContext(source, context)
  return { ...context.component.data(), ...context.component.methods }
}
test('下单附件翻页保留委托ID,切换委托时重置页码和旧列表', async () => {
  const requests = []
  const instance = orderComponent(params => {
    requests.push(params)
    return Promise.resolve({ code: 200, data: { records: [], total: 30 } })
  })
  instance.handleFileLook({ id: 123 })
  instance.paginationFile({ page: 2, limit: 10 })
  assert.equal(requests[1].insOrderId, 123)
  assert.equal(requests[1].current, 2)
  assert.equal(requests[1].size, 10)
  instance.handleFileLook({ id: 456 })
  assert.equal(requests[2].insOrderId, 456)
  assert.equal(requests[2].current, 1)
  assert.equal(instance.tableDataFile.length, 0)
})