吴羡
20 小时以前 53bc8cc8fd330d5e2a4a3698967548a0b74a395a
Merge remote-tracking branch 'origin/master'
已修改9个文件
已添加6个文件
963 ■■■■■ 文件已修改
package.json 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/business/insReport.js 34 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/components/ReportWordEditor/index.vue 233 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/utils/detectionTimeRange.js 17 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/utils/loadSuperDoc.js 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/utils/reportDocxLayout.js 164 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/utils/superdocToolbarZh.js 54 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/business/inspectionTask/components/InspectionWord.vue 21 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/business/inspectionTask/inspection.vue 27 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/business/inspectionView/index.vue 23 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/business/productOrder/components/addOrder.vue 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/business/productOrder/index.vue 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/business/reportPreparation/index.vue 320 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/standard/model/index.vue 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tests/detection-time-range.test.mjs 30 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
package.json
@@ -36,6 +36,7 @@
    "js-cookie": "3.0.5",
    "jsbarcode": "^3.12.3",
    "jsencrypt": "3.3.2",
    "jszip": "3.10.2",
    "jspdf": "^2.5.2",
    "mammoth": "^1.13.0",
    "nprogress": "0.2.0",
@@ -45,6 +46,7 @@
    "qrcode.vue": "^3.11.0",
    "sortablejs": "^1.15.7",
    "splitpanes": "^3.2.0",
    "superdoc": "2.25.0",
    "vue": "3.5.26",
    "vue-cropper": "1.1.1",
    "vue-router": "4.6.4",
src/api/business/insReport.js
@@ -36,6 +36,40 @@
  })
}
// èŽ·å–å®¡æ ¸é˜¶æ®µçš„ Word åœ¨çº¿ç¼–辑配置
export function getReportEditConfig(id, reportType) {
  return request({
    url: '/insReport/editor/config',
    method: 'get',
    params: { id, reportType }
  })
}
// æµè§ˆå™¨è¯»å–二进制,不触发用户下载。
export function getReportEditDocument(token, onDownloadProgress) {
  return request({
    url: '/insReport/editor/document',
    method: 'get',
    params: { token },
    responseType: 'blob',
    onDownloadProgress
  })
}
// ç¼–辑器导出的 DOCX ç›´æŽ¥æäº¤ LIMS,用户无需下载、选文件或手动上传。
export function saveReportEditDocument(token, version, blob) {
  const data = new FormData()
  data.append('token', token)
  data.append('version', version)
  data.append('file', blob, 'report.docx')
  return request({
    url: '/insReport/editor/document',
    method: 'post',
    data,
    headers: { 'Content-Type': 'multipart/form-data', repeatSubmit: false }
  })
}
// é‡æ–°ç”ŸæˆæŠ¥å‘Š
export function regenerateReport(id) {
  return request({
src/components/ReportWordEditor/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,233 @@
<template>
  <div class="report-word-editor" @keydown.ctrl.s.prevent="requestSave" @keydown.meta.s.prevent="requestSave">
    <div class="page-settings">
      <span>页边距(毫米,应用到全文)</span>
      <label v-for="(label, side) in marginLabels" :key="side">
        {{ label }} <input v-model.number="margins[side]" type="number" min="0" max="80" step="0.1"
                           :aria-label="`${label}页边距(毫米)`" :disabled="readonly || !ready || applyingMargins" />
      </label>
      <el-button size="small" :loading="applyingMargins" :disabled="readonly || !ready" @click="applyMargins">应用页边距</el-button>
    </div>
    <div ref="toolbar" class="word-toolbar" :inert="readonly || undefined"></div>
    <div class="word-canvas" :inert="readonly || undefined">
      <div ref="canvas"></div>
    </div>
    <div v-if="!ready || error" class="editor-status">
      <span>{{ error || '正在加载 Word æ–‡æ¡£â€¦' }}</span>
      <el-button v-if="error" @click="openDocument">重试</el-button>
    </div>
    <div v-if="readonly" class="editor-busy">正在保存或确认审核,请稍候</div>
  </div>
</template>
<script setup>
import { onMounted, onBeforeUnmount, ref, shallowRef, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { normalizeReportDocxForEditor } from '@/utils/reportDocxLayout'
import { loadSuperDoc } from '@/utils/loadSuperDoc'
import { superdocToolbarZh } from '@/utils/superdocToolbarZh'
import 'superdoc/style.css'
const props = defineProps({ document: { type: Object, required: true }, readonly: Boolean })
const emit = defineEmits(['ready', 'error', 'change', 'save'])
const canvas = ref(null)
const toolbar = ref(null)
const editor = shallowRef(null)
const ready = ref(false)
const applyingMargins = ref(false)
const error = ref('')
const margins = ref({ top: 25.4, bottom: 25.4, left: 25.4, right: 25.4 })
const marginLabels = { top: '上', bottom: '下', left: 'å·¦', right: '右' }
let generation = 0
let readyTimer
let toolbarObserver
let fontSelectionTarget = false
let fontSelectionKnown = false
function rememberFontSelection(event) {
  if (!event.target?.closest?.('[data-item="btn-fontFamily"]')) return
  const selection = editor.value?.ui?.selection?.getSnapshot?.()
  fontSelectionKnown = selection?.status === 'ready'
  fontSelectionTarget = Boolean(selection?.target || selection?.selectionTarget)
}
function onFontOptionMousedown(event) {
  if (!event.target?.closest?.('.sd-font-combobox__option')) return
  if (!ready.value || !fontSelectionKnown || fontSelectionTarget) return
  const owner = toolbar.value?.querySelector('[data-superdoc-ui-owner]')?.getAttribute('data-superdoc-ui-owner')
  const menuOwner = event.target.closest('[data-superdoc-ui-owner]')?.getAttribute('data-superdoc-ui-owner')
  if (owner && menuOwner && owner !== menuOwner) return
  ElMessage.info('请先在报告中选中文字,或将光标放入正文,再选择字体')
}
function localizeMeasurementUnit(includeMenu = false) {
  // SuperDoc 2.25 æŠŠå•位的当前值及两个菜单项写死为英文,strings é…ç½®æ— æ³•覆盖。
  const label = toolbar.value?.querySelector('[data-item="btn-measurementUnit"] > .sd-button-label')
  if (label?.textContent === 'in') label.textContent = '英寸'
  if (label?.textContent === 'cm') label.textContent = '厘米'
  if (!includeMenu) return
  for (const option of document.querySelectorAll('[data-item="btn-measurement-unit-option"]')) {
    const text = option.querySelector('.toolbar-dropdown-option__label')
    if (text?.textContent === 'Inches') {
      text.textContent = '英寸'
      option.setAttribute('aria-label', '度量单位 - è‹±å¯¸')
    } else if (text?.textContent === 'Centimeters') {
      text.textContent = '厘米'
      option.setAttribute('aria-label', '度量单位 - åŽ˜ç±³')
    }
  }
}
function onMeasurementUnitClick(event) {
  if (event.target?.closest?.('[data-item="btn-measurementUnit"], [data-item="btn-measurement-unit-option"]')) {
    requestAnimationFrame(() => localizeMeasurementUnit(true))
  }
}
function fail(cause, current) {
  if (current !== generation) return
  clearTimeout(readyTimer)
  error.value = cause?.message || String(cause || 'Word ç¼–辑器加载失败')
  emit('error', error.value)
}
async function openDocument() {
  const current = ++generation
  clearTimeout(readyTimer)
  editor.value?.destroy()
  editor.value = null
  fontSelectionTarget = false
  fontSelectionKnown = false
  ready.value = false
  error.value = ''
  try {
    const [documentBlob, { SuperDoc }] = await Promise.all([
      normalizeReportDocxForEditor(props.document.blob, props.document.code),
      loadSuperDoc()
    ])
    if (current !== generation) return
    readyTimer = setTimeout(() => fail(new Error('Word åŠ è½½è¶…æ—¶ï¼Œè¯·é‡è¯•'), current), 60000)
    editor.value = new SuperDoc({
      selector: canvas.value,
      document: new File([documentBlob], props.document.title, {
        type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
      }),
      documentMode: 'editing',
      role: 'editor',
      telemetry: { enabled: false },
      ui: { toolbar: { container: toolbar.value, strings: superdocToolbarZh }, ruler: true, comments: false },
      onReady: async ({ superdoc }) => {
        if (current !== generation) return
        clearTimeout(readyTimer)
        try {
          const result = await superdoc.activeEditor.doc.sections.list({ limit: 1 })
          const initial = result.items[0]?.margins
          if (initial) {
            for (const side of Object.keys(marginLabels)) {
              if (initial[side] != null) margins.value[side] = Math.round(initial[side] * 25.4 * 10) / 10
            }
          }
          ready.value = true
          localizeMeasurementUnit()
          emit('ready')
        } catch (cause) { fail(cause, current) }
      },
      onEditorUpdate: () => { if (current === generation && ready.value) emit('change') },
      onContentError: ({ error: cause }) => fail(cause, current),
      onException: (event) => {
        if (current !== generation) return
        // æ— æ•ˆé€‰åŒºç­‰å¯æ¢å¤çš„编辑命令错误不应强制重载并丢弃用户尚未保存的内容。
        if (ready.value && !event.workerFailure && event.stage !== 'document-init') {
          ElMessage.warning(event.error?.message || String(event.error || '操作未完成,请调整选区后重试'))
        } else {
          fail(event.error, current)
        }
      }
    })
  } catch (cause) { fail(cause, current) }
}
async function applyMargins() {
  if (!ready.value || props.readonly || applyingMargins.value) return
  applyingMargins.value = true
  try {
    const sections = editor.value.activeEditor.doc.sections
    if (Object.values(margins.value).some(mm => typeof mm !== 'number' || !Number.isFinite(mm) || mm < 0 || mm > 80)) {
      throw new Error('页边距请输入 0 åˆ° 80 æ¯«ç±³çš„æ•°å€¼')
    }
    // SuperDoc 2.25 çš„公开页属性 API ä½¿ç”¨è‹±å¯¸ï¼›å¯¼å‡ºå™¨è´Ÿè´£è½¬æ¢ä¸º DOCX twips。
    const input = Object.fromEntries(Object.entries(margins.value).map(([side, mm]) => [side, mm / 25.4]))
    // åˆ†é¡µè¯»å–全部节,修改真正的 OOXML èŠ‚é¡µè¾¹è·ï¼Œè€Œä¸æ˜¯ CSS。
    let offset = 0
    while (true) {
      const result = await sections.list({ limit: 100, offset })
      for (const section of result.items) {
        const receipt = await sections.setPageMargins({ target: section.address, ...input })
        if (!receipt.success && receipt.failure?.code !== 'NO_OP') throw new Error(receipt.failure?.message || '修改页边距失败')
      }
      offset += result.items.length
      if (offset >= result.total || result.items.length === 0) break
    }
    emit('change')
    ElMessage.success('页边距已应用,点击保存后生效')
  } catch (cause) { ElMessage.error(cause.message || '修改页边距失败') }
  finally { applyingMargins.value = false }
}
async function exportDocx() {
  if (!ready.value || error.value || !editor.value) throw new Error('Word å°šæœªåŠ è½½å®Œæˆï¼Œä¸èƒ½ä¿å­˜')
  if (applyingMargins.value) throw new Error('页边距正在应用,请稍候再保存')
  const blob = await editor.value.export({ exportType: ['docx'], triggerDownload: false })
  if (!(blob instanceof Blob) || !blob.size) throw new Error('Word å¯¼å‡ºå¤±è´¥')
  return blob
}
function requestSave() { if (ready.value && !props.readonly) emit('save') }
watch(() => props.document, openDocument)
onMounted(() => {
  toolbarObserver = new MutationObserver(() => localizeMeasurementUnit())
  toolbarObserver.observe(toolbar.value, { childList: true, characterData: true, subtree: true })
  document.addEventListener('click', onMeasurementUnitClick)
  toolbar.value.addEventListener('mousedown', rememberFontSelection, true)
  document.addEventListener('mousedown', onFontOptionMousedown, true)
  openDocument()
})
onBeforeUnmount(() => {
  generation++
  clearTimeout(readyTimer)
  editor.value?.destroy()
  toolbarObserver?.disconnect()
  document.removeEventListener('click', onMeasurementUnitClick)
  toolbar.value?.removeEventListener('mousedown', rememberFontSelection, true)
  document.removeEventListener('mousedown', onFontOptionMousedown, true)
})
defineExpose({ exportDocx })
</script>
<style scoped>
/* ç¼–辑器使用报告 DOCX çš„字体名;显式绑定本机 Word å­—体供浏览器排版。 */
@font-face { font-family: '宋体'; src: local('SimSun'), local('宋体'); }
@font-face { font-family: '黑体'; src: local('SimHei'), local('黑体'); }
.report-word-editor { height: 100%; position: relative; display: flex; flex-direction: column; border: 1px solid #dcdfe6; }
.page-settings { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; padding: 8px; background: #fff; }
.page-settings label { display: flex; align-items: center; gap: 4px; }
.page-settings input { width: 70px; padding: 5px; border: 1px solid #dcdfe6; border-radius: 4px; }
.word-toolbar { flex-shrink: 0; }
.word-canvas { flex: 1; min-height: 0; overflow: auto; background: #f3f4f6; }
/* SuperDoc æ»šåŠ¨æ—¶ä¼šæŠŠæ–‡æ¡£å®¹å™¨ç¼©åˆ°çº¸å¼ å®½åº¦ï¼Œå¯¼è‡´æ•´é¡µé‡æ–°è´´åˆ°å·¦è¾¹ã€‚ */
.word-canvas :deep(.superdoc),
.word-canvas :deep(.superdoc__layers),
.word-canvas :deep(.superdoc__document),
.word-canvas :deep(.superdoc__sub-document),
.word-canvas :deep(.v2-super-editor),
.word-canvas :deep(.v2-super-editor__visuals),
.word-canvas :deep(.v2-super-editor__stage) { width: 100% !important; }
.word-canvas :deep(.v2-super-editor__stage) { display: flex !important; flex-direction: column !important; align-items: center !important; }
.word-canvas :deep(.v2-super-editor__stage > *) { margin-inline: auto !important; }
.word-canvas :deep(.superdoc-layout) { margin-inline: auto !important; }
.word-canvas :deep(.superdoc-page) { margin-inline: auto !important; }
/* æ™®é€šèœå•和字体选项都挂在 body ä¸Šï¼›é»˜è®¤å±‚级 2000 ä½ŽäºŽå®¡æ ¸å¼¹çª—。 */
:global(.sd-toolbar-dropdown-menu[data-superdoc-ui-owner]),
:global(.sd-font-combobox__listbox[data-superdoc-ui-owner]) { z-index: 4000 !important; }
.editor-status, .editor-busy { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 12px; background: rgba(255,255,255,.95); z-index: 10; }
.editor-busy { background: rgba(255,255,255,.6); }
</style>
src/utils/detectionTimeRange.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,17 @@
// åŽ†å²å•æ—¥è®°å½•æŒ‰åŒä¸€å¤©çš„èŒƒå›´å›žæ˜¾ï¼ŒæŽ¥å£ä¿ç•™ detectionTime ä½œä¸ºå¼€å§‹æ—¥æœŸã€‚
export function readDetectionTimeRange(condition) {
  return condition.detectionTime
    ? [condition.detectionTime, condition.detectionEndTime || condition.detectionTime]
    : null
}
export function isCompleteDetectionTimeRange(range) {
  return Array.isArray(range) && range.length === 2 && !!range[0] && !!range[1] && range[0] <= range[1]
}
export function toDetectionTimePayload(range) {
  return {
    detectionTime: range?.[0] || null,
    detectionEndTime: range?.[1] || null,
  }
}
src/utils/loadSuperDoc.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,12 @@
let modulePromise
// å®¡æ ¸å¼€å§‹æ—¶é¢„加载;编辑器复用同一次加载,失败后仍允许重试。
export function loadSuperDoc() {
  if (!modulePromise) {
    modulePromise = import('superdoc').catch(error => {
      modulePromise = null
      throw error
    })
  }
  return modulePromise
}
src/utils/reportDocxLayout.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,164 @@
import JSZip from 'jszip'
const resultTablePattern = /<w:tbl(?:\s[^>]*)?>[\s\S]*?<\/w:tbl>/g
const floatingPositionPattern = /<w:tblpPr\b[^>]*\/>/
const tableIndentPattern = /<w:tblInd\b[^>]*\/>/
const runFontsPattern = /<w:rFonts\b[^>]*\/>/g
function alignReportHeader(xml, reportCode) {
  // æ—§æ£€éªŒæŠ¥å‘ŠæŠŠé¡µç æ”¾åœ¨æµ®åŠ¨æ–‡æœ¬æ¡†ä¸­ï¼›SuperDoc é‡æ–°åˆ†é¡µæ—¶ä¼šä¸¢å¤± Word çš„æ¨ªå‘锚点。
  // ä¿ç•™æŠ¥å‘Šç¼–号的所有 Word runs(模板填充值可能被拆到下一 run),只重建页码段落。
  if (!xml.includes('报告编号') || !/\bPAGE\b/.test(xml) || !/\bNUMPAGES\b/.test(xml)) return null
  const wordNamespace = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'
  const document = new DOMParser().parseFromString(xml, 'application/xml')
  const header = document.documentElement
  if (header.localName !== 'hdr' || document.getElementsByTagName('parsererror').length) return null
  const children = node => Array.from(node.childNodes).filter(child => child.nodeType === 1)
  const paragraphs = children(header).filter(node => node.localName === 'p')
  const reportParagraph = paragraphs.find(paragraph =>
    children(paragraph).some(run => run.localName === 'r' && run.textContent.includes('报告编号')))
  if (!reportParagraph) return null
  const reportRuns = children(reportParagraph).filter(node => node.localName === 'r')
  const labelIndex = reportRuns.findIndex(run => run.textContent.includes('报告编号'))
  if (labelIndex < 0) return null
  const reportText = reportRuns.slice(labelIndex).map(run => run.textContent).join('')
  const missingCode = !reportText.replace(/^.*?报告编号\s*[::]\s*/, '').trim() && reportCode?.trim()
  const alreadyAligned = paragraphs.length === 2 && paragraphs[0] === reportParagraph
    && !xml.includes('w:txbxContent')
    && paragraphs[1].textContent.includes('第')
  if (alreadyAligned && !missingCode) return null
  const element = name => document.createElementNS(wordNamespace, `w:${name}`)
  const appendRun = (paragraph, name, value, preserveSpace = false) => {
    const run = element('r')
    const text = element(name)
    if (preserveSpace) text.setAttribute('xml:space', 'preserve')
    text.textContent = value
    run.appendChild(text)
    paragraph.appendChild(run)
  }
  const appendField = (paragraph, name, fallback) => {
    for (const kind of ['begin', 'separate', 'end']) {
      const run = element('r')
      const marker = element('fldChar')
      marker.setAttributeNS(wordNamespace, 'w:fldCharType', kind)
      run.appendChild(marker)
      paragraph.appendChild(run)
      if (kind === 'begin') appendRun(paragraph, 'instrText', ` ${name} `, true)
      if (kind === 'separate') appendRun(paragraph, 't', fallback)
    }
  }
  const alignedReport = element('p')
  const reportProperties = element('pPr')
  const reportJustification = element('jc')
  reportJustification.setAttributeNS(wordNamespace, 'w:val', 'right')
  reportProperties.appendChild(reportJustification)
  alignedReport.appendChild(reportProperties)
  for (const run of reportRuns.slice(labelIndex)) alignedReport.appendChild(run.cloneNode(true))
  if (missingCode) appendRun(alignedReport, 't', ` ${reportCode.trim()}`, true)
  const pageParagraph = element('p')
  const pageProperties = reportProperties.cloneNode(true)
  const indent = element('ind')
  indent.setAttributeNS(wordNamespace, 'w:right', '300')
  pageProperties.appendChild(indent)
  pageParagraph.appendChild(pageProperties)
  appendRun(pageParagraph, 't', '第 ', true)
  appendField(pageParagraph, 'PAGE', '1')
  appendRun(pageParagraph, 't', ' é¡µ å…± ', true)
  appendField(pageParagraph, 'NUMPAGES', '4')
  appendRun(pageParagraph, 't', ' é¡µ', true)
  while (header.firstChild) header.removeChild(header.firstChild)
  header.appendChild(alignedReport)
  header.appendChild(pageParagraph)
  return new XMLSerializer().serializeToString(document)
}
function chineseThemeFont(themeXml, kind) {
  const fontGroup = themeXml.match(new RegExp(`<a:${kind}Font\\b[^>]*>[\\s\\S]*?<\\/a:${kind}Font>`))?.[0]
  if (!fontGroup) return null
  // Word æŒ‰ä¸­æ–‡è¯­è¨€åŒºåŸŸé€‰ Hans;主题中的 a:ea å¸¸ä¸ºç©ºï¼Œä¸èƒ½æŠŠå®ƒå½“成实际字体。
  const hansFont = [...fontGroup.matchAll(/<a:font\b[^>]*\/>/g)]
    .find(([tag]) => /\bscript="Hans"/.test(tag))?.[0]
  return hansFont?.match(/\btypeface="([^"]+)"/)?.[1]
    || fontGroup.match(/<a:ea\b[^>]*\btypeface="([^"]+)"/)?.[1]
    || null
}
function materializeChineseThemeFonts(xml, themeFonts) {
  let changed = false
  const normalized = xml.replace(runFontsPattern, tag => {
    let result = tag
    for (const script of ['ascii', 'hAnsi', 'eastAsia', 'cs']) {
      const themeAttribute = new RegExp(`\\s+w:${script}Theme="(majorEastAsia|minorEastAsia)"`)
      const theme = result.match(themeAttribute)?.[1]
      const font = theme === 'majorEastAsia' ? themeFonts.major : themeFonts.minor
      if (!font || !theme) continue
      // OOXML ä¸­ä¸»é¢˜å­—体优先于同脚本的显式字体;解析成 Word å®žé™…使用的字体。
      result = result.replace(themeAttribute, '')
      const directAttribute = new RegExp(`\\s+w:${script}="[^"]*"`)
      result = directAttribute.test(result)
        ? result.replace(directAttribute, ` w:${script}="${font}"`)
        : result.replace(/\s*\/>$/, ` w:${script}="${font}"/>`)
      changed = true
    }
    return result
  })
  return changed ? normalized : null
}
function alignResultTable(xml) {
  let changed = false
  const documentXml = xml.replace(resultTablePattern, (table, offset) => {
    if (!table.includes('标准要求值') || !table.includes('检测结果')) return table
    const position = table.match(floatingPositionPattern)?.[0]
    if (!position?.includes('w:horzAnchor="page"')) return table
    // æ—§ç‰ˆç»“果表使用相对页面的固定坐标;沿用前一张正文表的缩进才能保持同一左边线。
    const precedingTables = [...xml.slice(0, offset).matchAll(resultTablePattern)]
    const precedingIndent = precedingTables.reverse()
      .map(match => match[0].match(tableIndentPattern)?.[0]?.match(/w:w="(-?\d+)"/)?.[1])
      .find(value => value != null)
    const indent = precedingIndent ?? '-670'
    changed = true
    return table.replace(position, '').replace(tableIndentPattern,
      `<w:tblInd w:w="${indent}" w:type="dxa"/>`)
  })
  return changed ? documentXml : null
}
export async function normalizeReportDocxForEditor(blob, reportCode) {
  const zip = await JSZip.loadAsync(blob)
  const documentPart = zip.file('word/document.xml')
  if (!documentPart) return blob
  const documentXml = await documentPart.async('string')
  const themePart = zip.file('word/theme/theme1.xml')
  const themeXml = themePart ? await themePart.async('string') : ''
  const themeFonts = { major: chineseThemeFont(themeXml, 'major'), minor: chineseThemeFont(themeXml, 'minor') }
  let normalizedXml = alignResultTable(documentXml) || documentXml
  normalizedXml = materializeChineseThemeFonts(normalizedXml, themeFonts) || normalizedXml
  let changed = normalizedXml !== documentXml
  if (changed) zip.file('word/document.xml', normalizedXml)
  const stylesPart = zip.file('word/styles.xml')
  if (stylesPart) {
    const stylesXml = await stylesPart.async('string')
    const normalizedStyles = materializeChineseThemeFonts(stylesXml, themeFonts)
    if (normalizedStyles) {
      zip.file('word/styles.xml', normalizedStyles)
      changed = true
    }
  }
  const headerNames = Object.keys(zip.files).filter(name => /^word\/header\d+\.xml$/.test(name))
  for (const headerName of headerNames) {
    const headerXml = await zip.file(headerName).async('string')
    const normalizedHeader = alignReportHeader(headerXml, reportCode)
    if (normalizedHeader) {
      zip.file(headerName, normalizedHeader)
      changed = true
    }
  }
  if (!changed) return blob
  return zip.generateAsync({ type: 'blob', compression: 'DEFLATE' })
}
src/utils/superdocToolbarZh.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,54 @@
// SuperDoc 2.25 çš„ ui.toolbar.strings:只改界面文案,不改 Word æ ·å¼ ID。
export const superdocToolbarZh = {
  undo: '撤销',
  redo: '重做',
  zoom: '缩放',
  'font-family': '字体',
  'font-size': '字号',
  bold: '加粗',
  italic: '斜体',
  underline: '下划线',
  strikethrough: '删除线',
  'text-color': '字体颜色',
  'highlight-color': '文字高亮',
  'clear-formatting': '清除格式',
  'copy-format': '格式刷',
  link: '插入链接',
  image: '插入图片',
  table: '插入表格',
  'table-actions': '表格操作',
  'insert-row-before': '在上方插入行',
  'insert-row-after': '在下方插入行',
  'insert-column-before': '在左侧插入列',
  'insert-column-after': '在右侧插入列',
  'delete-row': '删除行',
  'delete-column': '删除列',
  'delete-table': '删除表格',
  'remove-borders': '清除边框',
  'merge-cells': '合并单元格',
  'split-cell': '拆分单元格',
  'fix-tables': '修复表格',
  'text-align': '对齐方式',
  'bullet-list': '项目符号',
  'numbered-list': '编号',
  'indent-decrease': '减少缩进',
  'indent-increase': '增加缩进',
  'line-height': '行距',
  'linked-style': '样式',
  'linked-style-label': '正文',
  'measurement-unit': '度量单位',
  ruler: '显示或隐藏标尺',
  'formatting-marks': '显示或隐藏格式标记',
  search: '查找',
  'table-of-contents': '目录',
  watermark: '水印',
  'page-number': '页码',
  'track-changes-accept-selection': '接受所选修订',
  'track-changes-reject-selection': '拒绝所选修订',
  'document-mode-editing': '编辑',
  'document-mode-suggesting': '修订',
  'document-mode-viewing': '查看',
  'document-mode-editing-description': '直接编辑文档',
  'document-mode-suggesting-description': '以修订方式编辑',
  'document-mode-viewing-description': '只查看文档'
}
src/views/business/inspectionTask/components/InspectionWord.vue
@@ -34,9 +34,9 @@
              <span class="template-panel__unit">℃</span>
            </el-form-item>
            <el-form-item label="检测时间:">
              <el-date-picker v-model="currentOtherForm.detectionTime" :disabled="state > 1" type="date"
                value-format="YYYY-MM-DD" placeholder="请选择日期" size="small" style="width: 160px"
                @change="m => subOtherForm(m, 'detectionTime')"></el-date-picker>
              <el-date-picker v-model="currentOtherForm.detectionTimeRange" :disabled="state > 1" type="daterange"
                value-format="YYYY-MM-DD" range-separator="至" start-placeholder="开始日期" end-placeholder="结束日期" size="small" style="width: 260px"
                @change="m => subOtherForm(m, 'detectionTimeRange')"></el-date-picker>
            </el-form-item>
            <el-form-item label="检测地点:">
              <el-input v-model="currentOtherForm.detectionPlace" :disabled="state > 1" clearable
@@ -253,6 +253,7 @@
<script>
// Vue3 æ²¡æœ‰ this.$createElement(下面用 h é€  VNode å½“ $msgbox çš„ message)
import { h } from 'vue'
import { readDetectionTimeRange, isCompleteDetectionTimeRange, toDetectionTimePayload } from '@/utils/detectionTimeRange'
import excelFunction from '@/utils/excelFountion'
import UnPassDialog from "@/views/business/unpass/components/unPassDialog.vue";
import AddUnPass from "@/views/business/unpass/components/addUnPass.vue";
@@ -386,7 +387,7 @@
      numOptions: [],
      temperatureOptions: [],
      wareTableData: [],
      // æ£€æµ‹æ¡ä»¶æŒ‰æ¨¡æ¿ id åˆ†ç»„:{ [templateId]: { temperature, detectionTime, detectionPlace, deviceIds } }
      // æ£€æµ‹æ¡ä»¶æŒ‰æ¨¡æ¿ id åˆ†ç»„:{ [templateId]: { temperature, detectionTimeRange, detectionPlace, deviceIds } }
      otherForm: {},
      deviceOptions: [],
      equipForm: {
@@ -468,7 +469,7 @@
    currentOtherForm() {
      return this.otherForm[this.currentTable] || {
        temperature: null,
        detectionTime: null,
        detectionTimeRange: null,
        detectionPlace: null,
        deviceIds: [],
        insResult: null
@@ -514,7 +515,7 @@
        conditionList.forEach(item => {
          this.otherForm[item.templateId] = {
            temperature: item.temperature != null ? item.temperature : null,
            detectionTime: item.detectionTime != null ? item.detectionTime : null,
            detectionTimeRange: readDetectionTimeRange(item),
            detectionPlace: item.detectionPlace != null ? item.detectionPlace : null,
            deviceIds: Array.isArray(item.deviceIds) ? [...item.deviceIds] : [],
            insResult: item.insResult != null ? item.insResult : null
@@ -1429,7 +1430,7 @@
      if (!this.otherForm[templateId]) {
        this.otherForm[templateId] = {
          temperature: null,
          detectionTime: null,
          detectionTimeRange: null,
          detectionPlace: null,
          deviceIds: [],
          insResult: null
@@ -1444,7 +1445,9 @@
      }
      if (type === 'deviceIds') {
        payload.deviceIds = Array.isArray(m) ? [...m] : []
      } else if (type === 'detectionTime' || type === 'detectionPlace') {
      } else if (type === 'detectionTimeRange') {
        Object.assign(payload, toDetectionTimePayload(m))
      } else if (type === 'detectionPlace') {
        payload[type] = m === '' || m == null ? null : m
      } else {
        payload[type] = Number(m)
@@ -1462,7 +1465,7 @@
      this.tableLists.forEach(m => {
        const form = this.otherForm[m.templateId] || {}
        if (!form.temperature) missing.push(`请填写「${m.templateName}」的温度`)
        if (!form.detectionTime) missing.push(`请选择「${m.templateName}」的检测时间`)
        if (!isCompleteDetectionTimeRange(form.detectionTimeRange)) missing.push(`请选择「${m.templateName}」的检测时间`)
        if (!form.detectionPlace) missing.push(`请填写「${m.templateName}」的检测地点`)
        if (!form.deviceIds || form.deviceIds.length === 0) {
          missing.push(`请选择「${m.templateName}」的设备`)
src/views/business/inspectionTask/inspection.vue
@@ -177,9 +177,9 @@
            <span class="template-panel__unit">℃</span>
          </el-form-item>
          <el-form-item label="检测时间:">
            <el-date-picker v-model="currentOtherForm.detectionTime" :disabled="state > 1" type="date"
              value-format="YYYY-MM-DD" placeholder="请选择日期" size="small" style="width: 160px"
              @change="(m) => subOtherForm(m, 'detectionTime')"></el-date-picker>
            <el-date-picker v-model="currentOtherForm.detectionTimeRange" :disabled="state > 1" type="daterange"
              value-format="YYYY-MM-DD" range-separator="至" start-placeholder="开始日期" end-placeholder="结束日期" size="small" style="width: 260px"
              @change="(m) => subOtherForm(m, 'detectionTimeRange')"></el-date-picker>
          </el-form-item>
          <el-form-item label="检测地点:">
            <el-input v-model="currentOtherForm.detectionPlace" :disabled="state > 1" clearable placeholder="请输入检测地点"
@@ -490,6 +490,7 @@
<script>
// Vue3 æ²¡æœ‰ this.$createElement(下面用 h é€  VNode å½“ $msgbox çš„ message)
import { h } from 'vue'
import { readDetectionTimeRange, isCompleteDetectionTimeRange, toDetectionTimePayload } from '@/utils/detectionTimeRange'
import excelFunction from "@/utils/excelFountion";
import limsTable from "@/components/Table/lims-table.vue";
import UnPassDialog from "../unpass/components/unPassDialog.vue";
@@ -613,7 +614,7 @@
        inspectionItem: 1,
        inspectionItemSubclass: "20(常温)",
      },
      // æ£€æµ‹æ¡ä»¶ä¸Žæ£€éªŒç»“论按模板 id åˆ†ç»„:{ [templateId]: { temperature, detectionTime, detectionPlace, deviceIds, insResult } }
      // æ£€æµ‹æ¡ä»¶ä¸Žæ£€éªŒç»“论按模板 id åˆ†ç»„:{ [templateId]: { temperature, detectionTimeRange, detectionPlace, deviceIds, insResult } }
      otherForm: {},
      equipForm: {
        value0: null,
@@ -742,7 +743,7 @@
      return (
        this.otherForm[this.currentTable] || {
          temperature: null,
          detectionTime: null,
          detectionTimeRange: null,
          detectionPlace: null,
          deviceIds: [],
          insResult: null,
@@ -1035,7 +1036,7 @@
        (this.insOrder.templateConditions || []).forEach((item) => {
          this.otherForm[item.templateId] = {
            temperature: item.temperature != null ? item.temperature : null,
            detectionTime: item.detectionTime != null ? item.detectionTime : null,
            detectionTimeRange: readDetectionTimeRange(item),
            detectionPlace: item.detectionPlace != null ? item.detectionPlace : null,
            deviceIds: Array.isArray(item.deviceIds) ? [...item.deviceIds] : [],
            insResult: item.insResult != null ? item.insResult : null,
@@ -1181,7 +1182,7 @@
      if (!this.otherForm[templateId]) {
        this.otherForm[templateId] = {
          temperature: null,
          detectionTime: null,
          detectionTimeRange: null,
          detectionPlace: null,
          deviceIds: [],
          insResult: null,
@@ -1197,7 +1198,9 @@
        payload.templateId = this.currentTable;
        if (type === "deviceIds") {
          payload.deviceIds = Array.isArray(m) ? [...m] : [];
        } else if (type === "detectionTime" || type === "detectionPlace") {
        } else if (type === "detectionTimeRange") {
          Object.assign(payload, toDetectionTimePayload(m));
        } else if (type === "detectionPlace") {
          payload[type] = m === "" || m == null ? null : m;
        } else {
          payload[type] = Number(m);
@@ -1783,7 +1786,7 @@
        if (this.insOrder.ifsOrderType !== "02wg") {
          if (!form.temperature) missing.push(`请填写「${m.templateName}」的温度`);
        }
        if (!form.detectionTime) missing.push(`请选择「${m.templateName}」的检测时间`);
        if (!isCompleteDetectionTimeRange(form.detectionTimeRange)) missing.push(`请选择「${m.templateName}」的检测时间`);
        if (!form.detectionPlace) missing.push(`请填写「${m.templateName}」的检测地点`);
        if (!form.deviceIds || form.deviceIds.length === 0) {
          missing.push(`请选择「${m.templateName}」的设备`);
@@ -1909,7 +1912,7 @@
            id: this.insOrder.id,
            templateId: template.templateId,
            temperature: form.temperature == null ? null : form.temperature,
            detectionTime: form.detectionTime || null,
            ...toDetectionTimePayload(form.detectionTimeRange),
            detectionPlace: form.detectionPlace || null,
          });
        }));
@@ -2239,7 +2242,9 @@
    },
    handleDown(row) {
      downFile({ id: row.id }).then((res) => {
        this.$download.saveAs(res.data.fileUrl, row.fileName)
        const resourcePath = Number(res.data.type) === 1 ? "/img/" : "/word/";
        const fileUrl = this.javaApi + resourcePath + encodeURIComponent(res.data.fileUrl);
        this.$download.saveAs(fileUrl, row.fileName);
      })
        .catch((error) => { });
    },
src/views/business/inspectionView/index.vue
@@ -144,9 +144,9 @@
            <span class="template-panel__unit">℃</span>
          </el-form-item>
          <el-form-item label="检测时间:">
            <el-date-picker v-model="currentOtherForm.detectionTime" :disabled="state > 1" type="date"
                            value-format="YYYY-MM-DD" placeholder="请选择日期" size="small" style="width: 160px"
                            @change="m => subOtherForm(m, 'detectionTime')" />
            <el-date-picker v-model="currentOtherForm.detectionTimeRange" :disabled="state > 1" type="daterange"
                            value-format="YYYY-MM-DD" range-separator="至" start-placeholder="开始日期" end-placeholder="结束日期" size="small" style="width: 260px"
                            @change="m => subOtherForm(m, 'detectionTimeRange')" />
          </el-form-item>
          <el-form-item label="检测地点:">
            <el-input v-model="currentOtherForm.detectionPlace" :disabled="state > 1" clearable
@@ -491,6 +491,7 @@
<script>
// Vue3 æ²¡æœ‰ this.$createElement(下面用 h é€  VNode å½“ $msgbox çš„ message)
import { h } from 'vue'
import { readDetectionTimeRange, isCompleteDetectionTimeRange, toDetectionTimePayload } from '@/utils/detectionTimeRange'
import excelFunction from "@/utils/excelFountion";
import limsTable from "@/components/Table/lims-table.vue";
import UnPassDialog from "../unpass/components/unPassDialog.vue";
@@ -754,7 +755,7 @@
    currentOtherForm() {
      return this.otherForm[this.currentTable] || {
        temperature: null,
        detectionTime: null,
        detectionTimeRange: null,
        detectionPlace: null,
        deviceIds: [],
        insResult: null,
@@ -950,7 +951,7 @@
        (this.insOrder.templateConditions || []).forEach((item) => {
          this.otherForm[item.templateId] = {
            temperature: item.temperature != null ? item.temperature : null,
            detectionTime: item.detectionTime != null ? item.detectionTime : null,
            detectionTimeRange: readDetectionTimeRange(item),
            detectionPlace: item.detectionPlace != null ? item.detectionPlace : null,
            deviceIds: Array.isArray(item.deviceIds) ? [...item.deviceIds] : [],
            insResult: item.insResult != null ? item.insResult : null,
@@ -1463,7 +1464,7 @@
      if (templateId == null || this.otherForm[templateId]) return;
      this.otherForm[templateId] = {
        temperature: null,
        detectionTime: null,
        detectionTimeRange: null,
        detectionPlace: null,
        deviceIds: [],
        insResult: null,
@@ -1478,7 +1479,9 @@
        payload.templateId = this.currentTable;
        if (type === "deviceIds") {
          payload.deviceIds = Array.isArray(m) ? [...m] : [];
        } else if (type === "detectionTime" || type === "detectionPlace") {
        } else if (type === "detectionTimeRange") {
          Object.assign(payload, toDetectionTimePayload(m));
        } else if (type === "detectionPlace") {
          payload[type] = m === "" || m == null ? null : m;
        } else {
          payload[type] = Number(m);
@@ -2111,7 +2114,7 @@
        if (this.insOrder.ifsOrderType !== "02wg") {
          if (!form.temperature) missing.push(`请填写「${item.templateName}」的温度`);
        }
        if (!form.detectionTime) missing.push(`请选择「${item.templateName}」的检测时间`);
        if (!isCompleteDetectionTimeRange(form.detectionTimeRange)) missing.push(`请选择「${item.templateName}」的检测时间`);
        if (!form.detectionPlace) missing.push(`请填写「${item.templateName}」的检测地点`);
        if (!form.deviceIds || form.deviceIds.length === 0) {
          missing.push(`请选择「${item.templateName}」的设备`);
@@ -2467,7 +2470,9 @@
    },
    handleDown(row) {
      downFile({ id: row.id }).then((res) => {
        this.$download.saveAs(res.data.fileUrl, row.fileName)
        const resourcePath = Number(res.data.type) === 1 ? "/img/" : "/word/";
        const fileUrl = this.javaApi + resourcePath + encodeURIComponent(res.data.fileUrl);
        this.$download.saveAs(fileUrl, row.fileName);
      })
        .catch((error) => { });
    },
src/views/business/productOrder/components/addOrder.vue
@@ -2153,6 +2153,10 @@
    },
    // ä¿å­˜æ¨¡æ¿
    addTemplateDia() {
      if (this.addObj.standardTreeId && (this.templateBindingLoading || this.boundTemplateList.length === 0)) {
        this.$message.error('请至少保留一个原始记录模板')
        return
      }
      if (this.templateName) {
        this.templateLoading = true;
        addInsOrderTemplate({
@@ -2161,7 +2165,10 @@
          thing: JSON.stringify({
            addObj: this.addObj,
            sampleList: this.sampleList,
            selectTree: this.selectTree
            selectTree: this.selectTree,
            templateList: this.addObj.standardTreeId
              ? this.boundTemplateList.map((item, index) => ({templateId: item.templateId, sort: index}))
              : undefined
          })
        }).then(res => {
          this.templateLoading = false;
src/views/business/productOrder/index.vue
@@ -379,6 +379,7 @@
              clickFun: (row) => {
                this.download(row);
              },
              disabled: (row) => !this.canDownloadReport(row),
              showHide: (row) => {
                return this.tabIndex === 2 || this.tabIndex === 3 || this.tabIndex === 7
              }
@@ -871,12 +872,14 @@
      })
    },
    // ä¸‹è½½æŠ¥å‘Š
    canDownloadReport(row) {
      return !!(row && row.reportId && (row.urlS || row.url))
    },
    download(row) {
      let url = row.urlS ? row.urlS : row.url;
      if (!url) {
        this.$message.warning('该委托单暂无可下载的报告')
      if (!this.canDownloadReport(row)) {
        return
      }
      let url = row.urlS ? row.urlS : row.url;
      url = url.split('.')[0] + '.pdf'
      const link = document.createElement('a');
      link.href = this.javaApi + url;
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 => {
      this.$confirm('还原后将使用原系统报告,手动上传的版本将不再作为当前报告。是否继续?', '还原系统报告', {
        confirmButtonText: '确定',
        cancelButtonText: '取消',
        type: 'warning'
      }).then(() => {
        return upReportUrl({ id: row.id }).then(res => {
        if (res.code === 200) {
          this.$message.success('操作成功')
            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)}%)…`
      }
      this.issuedVisible = true;
      })
      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.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() {
    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;
src/views/standard/model/index.vue
@@ -41,6 +41,9 @@
        <el-form-item label="备注" prop="remark">
          <el-input v-model="copyForm.remark" clearable size="small"></el-input>
        </el-form-item>
        <el-form-item label="标准要求值" prop="standardRequirement">
          <el-input v-model="copyForm.standardRequirement" clearable size="small"></el-input>
        </el-form-item>
        <el-form-item label="ä»·æ ¼(元)" prop="price">
          <el-input v-model="copyForm.price" clearable size="small"></el-input>
        </el-form-item>
@@ -88,6 +91,7 @@
        number: "",
        name: "",
        remark: "",
        standardRequirement: "",
        price: "",
        id: "",
      },
@@ -103,6 +107,7 @@
        { label: "检验依据", prop: "number" },
        { label: "检验项目", prop: "name" },
        { label: "备注", prop: "remark" },
        { label: "标准要求值", prop: "standardRequirement" },
        { label: "ä»·æ ¼(元)", prop: "price" },
        { label: "创建用户", prop: "createUserName" },
        { label: "创建时间", prop: "createTime", width: "160" },
@@ -216,7 +221,7 @@
    copyTemplate(row) {
      this.title = "复制模版";
      this.isShowCopyTem = true;
      this.copyForm.id = row.id;
      this.copyForm = this.HaveJson(row);
    },
    // æ–°å¢ž/编辑/复制模板
    submitCopyInfo() {
@@ -228,6 +233,7 @@
            name: this.copyForm.name,
            number: this.copyForm.number,
            remark: this.copyForm.remark,
            standardRequirement: this.copyForm.standardRequirement,
            price: this.copyForm.price === "" || this.copyForm.price == null ? null : this.copyForm.price,
          };
          switch (this.title) {
tests/detection-time-range.test.mjs
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,30 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readDetectionTimeRange, isCompleteDetectionTimeRange, toDetectionTimePayload } from '../src/utils/detectionTimeRange.js'
test('历史单日记录以同日范围回显,保存时补齐结束日期', () => {
  const range = readDetectionTimeRange({ detectionTime: '2026-10-20' })
  assert.deepEqual(range, ['2026-10-20', '2026-10-20'])
  assert.equal(isCompleteDetectionTimeRange(range), true)
  assert.deepEqual(toDetectionTimePayload(range), {
    detectionTime: '2026-10-20', detectionEndTime: '2026-10-20',
  })
})
test('跨月范围保存后能完整回显', () => {
  const range = ['2026-09-30', '2026-10-23']
  assert.equal(isCompleteDetectionTimeRange(range), true)
  assert.deepEqual(readDetectionTimeRange(toDetectionTimePayload(range)), range)
})
test('清空范围时同时清空开始和结束日期', () => {
  assert.deepEqual(toDetectionTimePayload(null), { detectionTime: null, detectionEndTime: null })
  assert.equal(readDetectionTimeRange(toDetectionTimePayload(null)), null)
  assert.equal(isCompleteDetectionTimeRange(null), false)
})
test('未选完整范围或结束日期早于开始日期时不能提交', () => {
  for (const range of [[], ['2026-10-23'], ['2026-10-23', null], ['2026-10-23', '2026-10-20']]) {
    assert.equal(isCompleteDetectionTimeRange(range), false)
  }
})