From 5d9a3141d4b3677bf8155da16b435d2a8818793b Mon Sep 17 00:00:00 2001
From: liding <756868258@qq.com>
Date: 星期五, 23 十月 2026 17:44:08 +0800
Subject: [PATCH] feat(report): 引入 SuperDoc 在线编辑器实现报告审核功能 支持审核页面直接编辑报告 - 新增检测报告与检验报告切换功能,切换时自动保存当前编辑内容 - 检测时间从单日选择改为日期范围选择,兼容历史单日数据回显 - 标准模型新增标准要求值字段,复制模板时携带完整表单数据 - 产品委托单下载报告按钮增加可用性状态控制
---
src/views/business/inspectionTask/components/InspectionWord.vue | 21
src/views/business/reportPreparation/index.vue | 330 ++++++++++++++++--
src/utils/reportDocxLayout.js | 164 +++++++++
src/views/business/inspectionTask/inspection.vue | 23
src/views/business/productOrder/index.vue | 9
src/api/business/insReport.js | 34 ++
src/utils/superdocToolbarZh.js | 54 +++
src/utils/detectionTimeRange.js | 17 +
src/views/standard/model/index.vue | 8
src/views/business/productOrder/components/addOrder.vue | 9
src/utils/loadSuperDoc.js | 12
package.json | 2
src/views/business/inspectionView/index.vue | 19
src/components/ReportWordEditor/index.vue | 233 +++++++++++++
tests/detection-time-range.test.mjs | 30 +
15 files changed, 889 insertions(+), 76 deletions(-)
diff --git a/package.json b/package.json
index 2605259..d678fa9 100644
--- a/package.json
+++ b/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",
diff --git a/src/api/business/insReport.js b/src/api/business/insReport.js
index c52b317..d704bd2 100644
--- a/src/api/business/insReport.js
+++ b/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({
diff --git a/src/components/ReportWordEditor/index.vue b/src/components/ReportWordEditor/index.vue
new file mode 100644
index 0000000..58d2010
--- /dev/null
+++ b/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 || '姝e湪鍔犺浇 Word 鏂囨。鈥�' }}</span>
+ <el-button v-if="error" @click="openDocument">閲嶈瘯</el-button>
+ </div>
+ <div v-if="readonly" class="editor-busy">姝e湪淇濆瓨鎴栫‘璁ゅ鏍革紝璇风◢鍊�</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 鎶婂崟浣嶇殑褰撳墠鍊煎強涓や釜鑿滃崟椤瑰啓姝讳负鑻辨枃锛宻trings 閰嶇疆鏃犳硶瑕嗙洊銆�
+ 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 婊氬姩鏃朵細鎶婃枃妗e鍣ㄧ缉鍒扮焊寮犲搴︼紝瀵艰嚧鏁撮〉閲嶆柊璐村埌宸﹁竟銆� */
+.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>
diff --git a/src/utils/detectionTimeRange.js b/src/utils/detectionTimeRange.js
new file mode 100644
index 0000000..10d5c44
--- /dev/null
+++ b/src/utils/detectionTimeRange.js
@@ -0,0 +1,17 @@
+// 鍘嗗彶鍗曟棩璁板綍鎸夊悓涓�澶╃殑鑼冨洿鍥炴樉锛屾帴鍙d繚鐣� 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,
+ }
+}
diff --git a/src/utils/loadSuperDoc.js b/src/utils/loadSuperDoc.js
new file mode 100644
index 0000000..a01ee88
--- /dev/null
+++ b/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
+}
diff --git a/src/utils/reportDocxLayout.js b/src/utils/reportDocxLayout.js
new file mode 100644
index 0000000..98ad551
--- /dev/null
+++ b/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) {
+ // 鏃ф楠屾姤鍛婃妸椤电爜鏀惧湪娴姩鏂囨湰妗嗕腑锛汼uperDoc 閲嶆柊鍒嗛〉鏃朵細涓㈠け 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' })
+}
diff --git a/src/utils/superdocToolbarZh.js b/src/utils/superdocToolbarZh.js
new file mode 100644
index 0000000..b612aa6
--- /dev/null
+++ b/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': '姝f枃',
+ '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': '鍙煡鐪嬫枃妗�'
+}
diff --git a/src/views/business/inspectionTask/components/InspectionWord.vue b/src/views/business/inspectionTask/components/InspectionWord.vue
index f8469d6..97a0b2a 100644
--- a/src/views/business/inspectionTask/components/InspectionWord.vue
+++ b/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}銆嶇殑璁惧`)
diff --git a/src/views/business/inspectionTask/inspection.vue b/src/views/business/inspectionTask/inspection.vue
index f16f59f..5e92482 100644
--- a/src/views/business/inspectionTask/inspection.vue
+++ b/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,
});
}));
diff --git a/src/views/business/inspectionView/index.vue b/src/views/business/inspectionView/index.vue
index fb429cf..8abe80f 100644
--- a/src/views/business/inspectionView/index.vue
+++ b/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}銆嶇殑璁惧`);
diff --git a/src/views/business/productOrder/components/addOrder.vue b/src/views/business/productOrder/components/addOrder.vue
index 0eac26c..17cd750 100644
--- a/src/views/business/productOrder/components/addOrder.vue
+++ b/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;
diff --git a/src/views/business/productOrder/index.vue b/src/views/business/productOrder/index.vue
index 6b1dde0..72952d9 100644
--- a/src/views/business/productOrder/index.vue
+++ b/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;
diff --git a/src/views/business/reportPreparation/index.vue b/src/views/business/reportPreparation/index.vue
index 0ea5aa2..495f00f 100644
--- a/src/views/business/reportPreparation/index.vue
+++ b/src/views/business/reportPreparation/index.vue
@@ -56,8 +56,9 @@
:disabled="scope.row.state != 0 || nickName !== scope.row.writeUserName">涓婁紶
</el-button>
</el-upload>
- <!-- <el-button type="text" size="small" :disabled="scope.row.state != 0 || nickName !== scope.row.writeUserName"-->
- <!-- @click="handleRestore(scope.row)">杩樺師</el-button>-->
+ <el-button v-if="scope.row.urlS && !isBrowserEditedReportUrl(scope.row.urlS)" link type="primary" size="small"
+ :disabled="scope.row.state != 0 || nickName !== scope.row.writeUserName || !scope.row.url"
+ @click="handleRestore(scope.row)">杩樺師绯荤粺鎶ュ憡</el-button>
<el-button link type="primary" size="small" @click="sendBackTask(scope.row)">閫�鍥炰换鍔�</el-button>
<el-button link type="primary" size="small" :disabled="scope.row.state != 0 || nickName !== scope.row.writeUserName"
@click="handle(scope.row)">鎻愪氦
@@ -97,18 +98,46 @@
</span></template>
</el-dialog>
<el-dialog :fullscreen="fullscreen" :modal-append-to-body="false" v-model="issuedVisible" title="鎶ュ憡瀹℃牳"
- width="80vw">
+ width="90vw" :close-on-click-modal="false" :before-close="closeAuditEditor">
<div class="full-screen">
<lims-icon name="el-icon-full-screen" v-if="!fullscreen" style="cursor: pointer;font-size: 18px"
@click="fullscreen = true;"></lims-icon>
<img v-else alt="" src="@/assets/images/no-full.svg" style="cursor: pointer;" @click="fullscreen = false;">
</div>
- <div v-if="issuedVisible" style="height: 70vh;">
- <onlyoffice ref="onlyoffice" :options="option" style="width: 100%;height: 100%;"/>
+ <div class="audit-editor-toolbar">
+ <el-radio-group v-if="currentInfo && currentInfo.inspectionUrl"
+ v-model="currentAuditReportType" :disabled="auditOpening || savingReport || loadingIssued || addApproverDia"
+ @change="handleAuditReportTypeChange">
+ <el-radio-button :value="0">妫�娴嬫姤鍛�</el-radio-button>
+ <el-radio-button :value="1">妫�楠屾姤鍛�</el-radio-button>
+ </el-radio-group>
+ <span class="margin-tip">淇敼瀛椾綋璇峰厛閫変腑鏂囧瓧鎴栧畾浣嶅厜鏍囷紱淇濆瓨鍚庣敓鏁�</span>
+ </div>
+ <div v-if="issuedVisible" class="audit-editor-content">
+ <report-word-editor v-if="auditDocument" :key="auditDocument.sessionToken" ref="auditWordEditor"
+ :document="auditDocument" :readonly="savingReport || loadingIssued || addApproverDia"
+ @ready="onAuditEditorReady" @error="onAuditEditorError"
+ @change="auditDirty = true" @save="saveAuditDraft"/>
+ <div v-if="auditOpening" class="audit-opening-overlay">
+ <div class="audit-opening-panel" role="status" aria-live="polite">
+ <div class="audit-opening-title">姝e湪鎵撳紑鎶ュ憡</div>
+ <el-progress :percentage="auditOpenProgress" :stroke-width="10"
+ :indeterminate="auditOpenProgress < 30 || auditOpenProgress >= 80" :duration="3"/>
+ <div class="audit-opening-step">{{ auditOpenText }}</div>
+ </div>
+ </div>
+ <div v-else-if="auditOpenError && !auditDocument" class="audit-opening-overlay">
+ <div class="audit-opening-panel audit-opening-error" role="alert">
+ <div class="audit-opening-title">鎶ュ憡鎵撳紑澶辫触</div>
+ <p>{{ auditOpenError }}</p>
+ <el-button type="primary" @click="handleIssued(currentInfo)">閲嶈瘯</el-button>
+ </div>
+ </div>
</div>
<template #footer><span class="dialog-footer">
- <el-button :disabled="loadingIssued" @click="issuedReasonVisible = true">涓嶉�氳繃</el-button>
- <el-button type="primary" @click="subIssued">閫� 杩�</el-button>
+ <el-button :disabled="loadingIssued || savingReport || !auditEditorReady || addApproverDia" @click="saveAuditDraft">淇濆瓨</el-button>
+ <el-button :disabled="auditOpening || !!auditOpenError || loadingIssued || savingReport || addApproverDia" @click="issuedReasonVisible = true">涓嶉�氳繃</el-button>
+ <el-button type="primary" :loading="savingReport" :disabled="loadingIssued || !auditEditorReady" @click="subIssued">閫� 杩�</el-button>
</span></template>
</el-dialog>
<el-dialog :modal-append-to-body="false" v-model="issuedReasonVisible" title="涓嶉�氳繃鍘熷洜" width="400px">
@@ -266,11 +295,15 @@
<script>
import onlyoffice from "@/components/Onlyoffice/onlyoffice.vue";
+import ReportWordEditor from "@/components/ReportWordEditor/index.vue";
// import ShowInfo from "../do/b1-material-ins-order/showInfo.vue";
import {
downAll,
downloadReport,
examineReport,
+ getReportEditDocument,
+ saveReportEditDocument,
+ getReportEditConfig,
pageInsReport,
regenerateReport,
ratifyReport,
@@ -284,10 +317,11 @@
import limsTable from "@/components/Table/lims-table.vue";
import {delFile, downFile, getFileList} from "@/api/business/rawMaterialOrder";
import {selectEnabledSeals} from "@/api/structural/sealConfig";
+import {loadSuperDoc} from "@/utils/loadSuperDoc";
export default {
name: 'ReportPreparation',
- components: {limsTable, onlyoffice},
+ components: {limsTable, onlyoffice, ReportWordEditor},
dicts: ['material_prop_type'],
data() {
return {
@@ -328,6 +362,18 @@
loadingApprove: false,
loadingIssuedReason: false,
loadingIssued: false,
+ savingReport: false,
+ auditEditorReady: false,
+ auditOpening: false,
+ auditOpenProgress: 0,
+ auditOpenText: '',
+ auditOpenError: '',
+ auditOpenGeneration: 0,
+ currentAuditReportType: 0,
+ loadedAuditReportType: null,
+ auditEditSessions: {},
+ auditDocument: null,
+ auditDirty: false,
value: ``,
reason: '',
currentInfo: null,
@@ -871,8 +917,9 @@
reader.readAsText(res)
return
}
- const reportUrl = reportType === 1 ? row.inspectionUrl : (row.urlS || row.url || '')
- const filename = decodeURIComponent(reportUrl.substring(reportUrl.lastIndexOf('/') + 1)) || `${row.code}.docx`
+ const reportNumber = String(row.code || '鎶ュ憡').replace(/[\\/:*?"<>|\x00-\x1f]/g, '-').trim()
+ const reportSuffix = row.inspectionUrl ? (reportType === 1 ? '-妫�楠屾姤鍛�' : '-妫�娴嬫姤鍛�') : ''
+ const filename = `${reportNumber}${reportSuffix}.docx`
this.$download.saveAs(res, filename)
this.downloadDialogVisible = false
}).catch(() => {
@@ -885,7 +932,15 @@
})
},
handleRegenerate(row) {
- this.$confirm('灏嗘牴鎹綋鍓嶅師濮嬭褰曢噸鏂扮敓鎴愬苟瑕嗙洊绯荤粺鎶ュ憡锛屾槸鍚︾户缁紵', '鎻愮ず', {
+ if (row.urlS && !this.isBrowserEditedReportUrl(row.urlS)) {
+ this.$message.warning('褰撳墠浣跨敤鎵嬪姩涓婁紶鐨勬姤鍛婏紝璇峰厛鐐瑰嚮鈥滆繕鍘熺郴缁熸姤鍛娾�濓紝鍐嶉噸鏂扮敓鎴�')
+ return
+ }
+ const replacingEditedReport = this.isBrowserEditedReportUrl(row.urlS)
+ const prompt = replacingEditedReport
+ ? '閲嶆柊鐢熸垚灏嗚鐩栧鏍搁〉闈繚瀛樼殑淇敼锛屾寜褰撳墠鍘熷璁板綍鐢熸垚鏂版姤鍛娿�傛槸鍚︾户缁紵'
+ : '灏嗘牴鎹綋鍓嶅師濮嬭褰曢噸鏂扮敓鎴愬苟瑕嗙洊绯荤粺鎶ュ憡锛屾槸鍚︾户缁紵'
+ this.$confirm(prompt, '鎻愮ず', {
confirmButtonText: '纭畾',
cancelButtonText: '鍙栨秷',
type: 'warning'
@@ -900,40 +955,153 @@
}).catch(() => {
})
},
- // 杩樺師鎿嶄綔
+ isBrowserEditedReportUrl(url) {
+ return /^\/word\/(?:edited|reviewed)-[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}\.docx$/i.test(url || '')
+ },
+ // 鍋滅敤鎵嬪姩涓婁紶鐨勬姤鍛婏紝鎭㈠浣跨敤绯荤粺鐢熸垚鐨勭増鏈��
handleRestore(row) {
- upReportUrl({
- id: row.id
- }).then(res => {
- if (res.code === 200) {
- this.$message.success('鎿嶄綔鎴愬姛')
- this.refreshTable('page')
- }
+ this.$confirm('杩樺師鍚庡皢浣跨敤鍘熺郴缁熸姤鍛婏紝鎵嬪姩涓婁紶鐨勭増鏈皢涓嶅啀浣滀负褰撳墠鎶ュ憡銆傛槸鍚︾户缁紵', '杩樺師绯荤粺鎶ュ憡', {
+ confirmButtonText: '纭畾',
+ cancelButtonText: '鍙栨秷',
+ type: 'warning'
+ }).then(() => {
+ return upReportUrl({ id: row.id }).then(res => {
+ if (res.code === 200) {
+ this.$message.success('宸茶繕鍘熺郴缁熸姤鍛婏紝鍙互閲嶆柊鐢熸垚')
+ this.refreshTable('page')
+ }
+ })
+ }).catch(() => {
})
},
// 瀹℃牳鎸夐挳
- handleIssued(row) {
- this.currentInfo = row;
- let fileName = (row.urlS === null || row.urlS === '') ? row.url : row.urlS
- let fileType = "docx"
- if (row.tempUrlPdf != null || row.tempUrlPdf === '') {
- fileName = row.tempUrlPdf
- fileType = "pdf"
+ async handleIssued(row) {
+ const generation = ++this.auditOpenGeneration
+ this.currentInfo = row
+ this.auditEditSessions = {}
+ this.auditDocument = null
+ this.auditEditorReady = false
+ this.auditDirty = false
+ this.loadedAuditReportType = null
+ this.currentAuditReportType = 0
+ this.auditOpening = true
+ this.auditOpenProgress = 5
+ this.auditOpenText = '姝e湪纭鎶ュ憡鐗堟湰鈥�'
+ this.auditOpenError = ''
+ this.issuedVisible = true
+ // 璁╃紪杈戝櫒鑴氭湰涓庢姤鍛婄増鏈姹傚悓鏃跺姞杞斤紝閬垮厤涓嬭浇瀹� Word 鍚庡啀绛夊緟鑴氭湰銆�
+ loadSuperDoc().catch(() => {})
+ try {
+ // 涓や唤鎶ュ憡閮界‘璁ょ増鏈紱鏈墦寮�鐨勬姤鍛婃部鐢ㄦ湇鍔$鎸佷箙鍖栫増鏈��
+ const types = row.inspectionUrl ? [0, 1] : [0]
+ const configs = await Promise.all(types.map(type => getReportEditConfig(row.id, type)))
+ if (generation !== this.auditOpenGeneration || !this.issuedVisible) return
+ types.forEach((type, index) => { this.auditEditSessions[type] = configs[index].data })
+ this.auditOpenProgress = 30
+ await this.loadAuditEditor(0, generation)
+ } catch (e) {
+ if (generation !== this.auditOpenGeneration || !this.issuedVisible) return
+ this.auditOpening = false
+ this.auditOpenError = e.message || '鎶ュ憡鎵撳紑澶辫触锛岃閲嶈瘯'
}
- fileName = fileName.replace('/word/', '')
- const userName = this.nickName
- this.option = {
- url: this.javaApi + "/word/" + fileName,
- isEdit: false,
- fileType: fileType,
- title: fileName,
- lang: 'zh-CN',
- isPrint: false,
- user_id: 1,
- user_name: userName,
- editUrl: this.javaApi + "/insReport/onlyOffice/save?fileName=" + fileName
+ },
+ async loadAuditEditor(reportType, generation = this.auditOpenGeneration) {
+ this.auditEditorReady = false
+ this.auditOpening = true
+ this.auditOpenProgress = Math.max(this.auditOpenProgress, 30)
+ const reportName = reportType === 0 ? '妫�娴嬫姤鍛�' : '妫�楠屾姤鍛�'
+ this.auditOpenText = `姝e湪涓嬭浇${reportName}鈥
+ const config = this.auditEditSessions[reportType]
+ const blob = await getReportEditDocument(config.sessionToken, event => {
+ if (generation !== this.auditOpenGeneration || !this.issuedVisible) return
+ const ratio = event.total > 0 ? event.loaded / event.total : event.progress
+ if (typeof ratio === 'number' && Number.isFinite(ratio)) {
+ this.auditOpenProgress = Math.max(this.auditOpenProgress, 30 + Math.round(Math.min(1, ratio) * 45))
+ this.auditOpenText = `姝e湪涓嬭浇${reportName}锛�${Math.round(Math.min(1, ratio) * 100)}%锛夆�
+ }
+ })
+ if (generation !== this.auditOpenGeneration || !this.issuedVisible) return false
+ if (!(blob instanceof Blob)) throw new Error('璇诲彇鎶ュ憡澶辫触')
+ if (blob.type.includes('json')) {
+ const error = JSON.parse(await blob.text())
+ throw new Error(error.msg || '璇诲彇鎶ュ憡澶辫触')
}
- this.issuedVisible = true;
+ this.auditOpenProgress = 80
+ this.auditOpenText = '姝e湪鎺掔増 Word 鏂囨。鈥�'
+ this.auditDocument = { ...config, blob, code: this.currentInfo?.code }
+ this.loadedAuditReportType = reportType
+ this.currentAuditReportType = reportType
+ this.auditDirty = false
+ return true
+ },
+ onAuditEditorReady() {
+ if (!this.issuedVisible || !this.auditDocument) return
+ this.auditEditorReady = true
+ this.auditOpenProgress = 100
+ this.auditOpenText = '鎶ュ憡宸叉墦寮�'
+ this.auditOpenError = ''
+ this.auditOpening = false
+ },
+ onAuditEditorError(error) {
+ if (!this.issuedVisible || !this.auditDocument) return
+ this.auditEditorReady = false
+ this.auditOpening = false
+ this.auditOpenError = error || 'Word 缂栬緫鍣ㄥ姞杞藉け璐�'
+ },
+ async handleAuditReportTypeChange(reportType) {
+ const previous = this.loadedAuditReportType
+ if (Number(reportType) === Number(previous)) return
+ this.savingReport = true
+ this.auditOpening = true
+ this.auditOpenProgress = 10
+ this.auditOpenText = '姝e湪淇濆瓨褰撳墠鎶ュ憡鈥�'
+ this.auditOpenError = ''
+ try {
+ await this.saveCurrentAuditReport()
+ await this.loadAuditEditor(Number(reportType))
+ } catch (e) {
+ this.currentAuditReportType = previous
+ this.auditEditorReady = !!this.auditDocument
+ this.auditOpening = false
+ this.auditOpenError = ''
+ this.$message.error(e.message || '鎶ュ憡淇濆瓨澶辫触锛屾棤娉曞垏鎹�')
+ } finally {
+ this.savingReport = false
+ }
+ },
+ async saveCurrentAuditReport() {
+ if (!this.auditEditorReady || !this.$refs.auditWordEditor) throw new Error('鎶ュ憡灏氭湭鍔犺浇瀹屾垚')
+ const config = this.auditEditSessions[this.loadedAuditReportType]
+ const blob = await this.$refs.auditWordEditor.exportDocx()
+ const res = await saveReportEditDocument(config.sessionToken, config.version, blob)
+ config.version = res.data.version
+ this.auditDirty = false
+ },
+ async saveAuditDraft() {
+ if (this.savingReport) return
+ this.savingReport = true
+ try {
+ await this.saveCurrentAuditReport()
+ this.$message.success('鎶ュ憡宸蹭繚瀛�')
+ this.refreshTable('page')
+ } catch (e) {
+ this.$message.error(e.message || '鎶ュ憡淇濆瓨澶辫触')
+ } finally {
+ this.savingReport = false
+ }
+ },
+ async closeAuditEditor(done) {
+ if (this.savingReport || this.loadingIssued || this.addApproverDia) return
+ if (this.auditDirty) {
+ try {
+ await this.$confirm('鏈夊皻鏈繚瀛樼殑缂栬緫锛屽叧闂皢涓㈠純杩欎簺淇敼銆傜‘璁ゅ叧闂紵', '鍏抽棴瀹℃牳')
+ } catch (_) { return }
+ }
+ this.auditOpenGeneration++
+ this.auditOpening = false
+ this.auditOpenError = ''
+ this.auditDocument = null
+ done()
},
// 鏌ョ湅鎶ュ憡
viewIssued(row) {
@@ -983,7 +1151,7 @@
});
},
// 瀹℃牳閫氳繃
- submitAddApprover() {
+ async submitAddApprover() {
if (!this.approver) {
this.$message.error('璇烽�夋嫨瀹℃壒浜�')
return
@@ -992,11 +1160,21 @@
this.$message.error('瀹℃牳浜哄拰鎵瑰噯浜轰笉鑳戒负鍚屼竴浜猴紝璇烽噸鏂伴�夋嫨鎵瑰噯浜�')
return
}
+ if (this.loadingIssued || this.savingReport) return
this.loadingIssued = true;
+ try {
+ // 閫夋嫨鎵瑰噯浜哄悗鍐嶆瀵煎嚭锛屽鏍稿缁堢粦瀹氬綋鍓嶇紪杈戝唴瀹广��
+ await this.saveCurrentAuditReport()
+ } catch (e) {
+ this.loadingIssued = false
+ this.$message.error(e.message || '鎶ュ憡淇濆瓨澶辫触')
+ return
+ }
examineReport({
id: this.currentInfo.id,
userId: this.approver,
- isExamine: 1
+ isExamine: 1,
+ editSessions: Object.fromEntries(Object.entries(this.auditEditSessions).map(([type, config]) => [type, config.sessionToken]))
}).then(res => {
this.loadingIssued = false;
this.$message.success('鎻愪氦鎴愬姛')
@@ -1013,9 +1191,22 @@
this.approver = ''
},
// 鐐瑰嚮閫氳繃锛岄渶瑕侀�夋嫨鎵瑰噯浜哄憳
- subIssued() {
- this.getAuthorizedPerson(true)
- this.addApproverDia = true
+ async subIssued() {
+ if (this.savingReport || this.loadingIssued) return
+ if (!this.auditEditorReady) {
+ this.$message.error('鎶ュ憡灏氭湭鍔犺浇瀹屾垚锛岃绋嶅悗閲嶈瘯')
+ return
+ }
+ this.savingReport = true
+ try {
+ await this.saveCurrentAuditReport()
+ this.getAuthorizedPerson(true)
+ this.addApproverDia = true
+ } catch (e) {
+ this.$message.error(e.message || 'Word 鎶ュ憡淇濆瓨澶辫触锛屾殏涓嶈兘瀹℃牳閫氳繃')
+ } finally {
+ this.savingReport = false
+ }
},
// 鑾峰彇浜哄憳鍒楄〃
getAuthorizedPerson(filterExamine) {
@@ -1237,6 +1428,57 @@
top: 22px;
}
+.audit-editor-toolbar {
+ min-height: 36px;
+ display: flex;
+ align-items: center;
+ gap: 16px;
+ margin-bottom: 8px;
+}
+
+.audit-editor-content {
+ position: relative;
+ height: 70vh;
+}
+
+.audit-opening-overlay {
+ position: absolute;
+ inset: 0;
+ z-index: 20;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: #fff;
+}
+
+.audit-opening-panel {
+ width: min(420px, calc(100% - 48px));
+ text-align: center;
+}
+
+.audit-opening-title {
+ margin-bottom: 20px;
+ font-size: 18px;
+ font-weight: 600;
+ color: #303133;
+}
+
+.audit-opening-step {
+ margin-top: 12px;
+ color: #606266;
+ font-size: 13px;
+}
+
+.audit-opening-error p {
+ margin: 0 0 20px;
+ color: #606266;
+}
+
+.margin-tip {
+ color: #606266;
+ font-size: 13px;
+}
+
.btns {
position: absolute;
right: 40px;
diff --git a/src/views/standard/model/index.vue b/src/views/standard/model/index.vue
index 9293e02..3c90eea 100644
--- a/src/views/standard/model/index.vue
+++ b/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) {
diff --git a/tests/detection-time-range.test.mjs b/tests/detection-time-range.test.mjs
new file mode 100644
index 0000000..0cae5e1
--- /dev/null
+++ b/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)
+ }
+})
--
Gitblit v1.9.3