feat(report): 引入 SuperDoc 在线编辑器实现报告审核功能
支持审核页面直接编辑报告
- 新增检测报告与检验报告切换功能,切换时自动保存当前编辑内容
- 检测时间从单日选择改为日期范围选择,兼容历史单日数据回显
- 标准模型新增标准要求值字段,复制模板时携带完整表单数据
- 产品委托单下载报告按钮增加可用性状态控制
| | |
| | | "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", |
| | |
| | | "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", |
| | |
| | | }) |
| | | } |
| | | |
| | | // è·åå®¡æ ¸é¶æ®µç 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({ |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <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> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | // åå²åæ¥è®°å½æåä¸å¤©çèå´åæ¾ï¼æ¥å£ä¿ç 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, |
| | | } |
| | | } |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | let modulePromise |
| | | |
| | | // å®¡æ ¸å¼å§æ¶é¢å è½½ï¼ç¼è¾å¨å¤ç¨å䏿¬¡å è½½ï¼å¤±è´¥åä»å
许éè¯ã |
| | | export function loadSuperDoc() { |
| | | if (!modulePromise) { |
| | | modulePromise = import('superdoc').catch(error => { |
| | | modulePromise = null |
| | | throw error |
| | | }) |
| | | } |
| | | return modulePromise |
| | | } |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | 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' }) |
| | | } |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | // 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': 'åªæ¥çææ¡£' |
| | | } |
| | |
| | | <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 |
| | |
| | | <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"; |
| | |
| | | numOptions: [], |
| | | temperatureOptions: [], |
| | | wareTableData: [], |
| | | // æ£æµæ¡ä»¶ææ¨¡æ¿ id åç»ï¼{ [templateId]: { temperature, detectionTime, detectionPlace, deviceIds } } |
| | | // æ£æµæ¡ä»¶ææ¨¡æ¿ id åç»ï¼{ [templateId]: { temperature, detectionTimeRange, detectionPlace, deviceIds } } |
| | | otherForm: {}, |
| | | deviceOptions: [], |
| | | equipForm: { |
| | |
| | | currentOtherForm() { |
| | | return this.otherForm[this.currentTable] || { |
| | | temperature: null, |
| | | detectionTime: null, |
| | | detectionTimeRange: null, |
| | | detectionPlace: null, |
| | | deviceIds: [], |
| | | insResult: null |
| | |
| | | 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 |
| | |
| | | if (!this.otherForm[templateId]) { |
| | | this.otherForm[templateId] = { |
| | | temperature: null, |
| | | detectionTime: null, |
| | | detectionTimeRange: null, |
| | | detectionPlace: null, |
| | | deviceIds: [], |
| | | insResult: null |
| | |
| | | } |
| | | 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) |
| | |
| | | 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}ãç设å¤`) |
| | |
| | | <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="请è¾å
¥æ£æµå°ç¹" |
| | |
| | | <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"; |
| | |
| | | inspectionItem: 1, |
| | | inspectionItemSubclass: "20(常温)", |
| | | }, |
| | | // æ£æµæ¡ä»¶ä¸æ£éªç»è®ºææ¨¡æ¿ id åç»ï¼{ [templateId]: { temperature, detectionTime, detectionPlace, deviceIds, insResult } } |
| | | // æ£æµæ¡ä»¶ä¸æ£éªç»è®ºææ¨¡æ¿ id åç»ï¼{ [templateId]: { temperature, detectionTimeRange, detectionPlace, deviceIds, insResult } } |
| | | otherForm: {}, |
| | | equipForm: { |
| | | value0: null, |
| | |
| | | return ( |
| | | this.otherForm[this.currentTable] || { |
| | | temperature: null, |
| | | detectionTime: null, |
| | | detectionTimeRange: null, |
| | | detectionPlace: null, |
| | | deviceIds: [], |
| | | insResult: null, |
| | |
| | | (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, |
| | |
| | | if (!this.otherForm[templateId]) { |
| | | this.otherForm[templateId] = { |
| | | temperature: null, |
| | | detectionTime: null, |
| | | detectionTimeRange: null, |
| | | detectionPlace: null, |
| | | deviceIds: [], |
| | | insResult: null, |
| | |
| | | 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); |
| | |
| | | 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}ãç设å¤`); |
| | |
| | | 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, |
| | | }); |
| | | })); |
| | |
| | | <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 |
| | |
| | | <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"; |
| | |
| | | currentOtherForm() { |
| | | return this.otherForm[this.currentTable] || { |
| | | temperature: null, |
| | | detectionTime: null, |
| | | detectionTimeRange: null, |
| | | detectionPlace: null, |
| | | deviceIds: [], |
| | | insResult: null, |
| | |
| | | (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, |
| | |
| | | if (templateId == null || this.otherForm[templateId]) return; |
| | | this.otherForm[templateId] = { |
| | | temperature: null, |
| | | detectionTime: null, |
| | | detectionTimeRange: null, |
| | | detectionPlace: null, |
| | | deviceIds: [], |
| | | insResult: null, |
| | |
| | | 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); |
| | |
| | | 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}ãç设å¤`); |
| | |
| | | }, |
| | | // ä¿åæ¨¡æ¿ |
| | | addTemplateDia() { |
| | | if (this.addObj.standardTreeId && (this.templateBindingLoading || this.boundTemplateList.length === 0)) { |
| | | this.$message.error('请è³å°ä¿çä¸ä¸ªåå§è®°å½æ¨¡æ¿') |
| | | return |
| | | } |
| | | if (this.templateName) { |
| | | this.templateLoading = true; |
| | | addInsOrderTemplate({ |
| | |
| | | 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; |
| | |
| | | clickFun: (row) => { |
| | | this.download(row); |
| | | }, |
| | | disabled: (row) => !this.canDownloadReport(row), |
| | | showHide: (row) => { |
| | | return this.tabIndex === 2 || this.tabIndex === 3 || this.tabIndex === 7 |
| | | } |
| | |
| | | }) |
| | | }, |
| | | // ä¸è½½æ¥å |
| | | 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; |
| | |
| | | :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)">æäº¤ |
| | |
| | | </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"> |
| | |
| | | |
| | | <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, |
| | |
| | | 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 { |
| | |
| | | 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, |
| | |
| | | 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(() => { |
| | |
| | | }) |
| | | }, |
| | | 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' |
| | |
| | | }).catch(() => { |
| | | }) |
| | | }, |
| | | // è¿åæä½ |
| | | isBrowserEditedReportUrl(url) { |
| | | return /^\/word\/(?:edited|reviewed)-[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}\.docx$/i.test(url || '') |
| | | }, |
| | | // åç¨æå¨ä¸ä¼ çæ¥åï¼æ¢å¤ä½¿ç¨ç³»ç»çæççæ¬ã |
| | | handleRestore(row) { |
| | | upReportUrl({ |
| | | id: row.id |
| | | }).then(res => { |
| | | if (res.code === 200) { |
| | | this.$message.success('æä½æå') |
| | | this.refreshTable('page') |
| | | } |
| | | this.$confirm('è¿ååå°ä½¿ç¨åç³»ç»æ¥åï¼æå¨ä¸ä¼ ççæ¬å°ä¸åä½ä¸ºå½åæ¥åãæ¯å¦ç»§ç»ï¼', 'è¿åç³»ç»æ¥å', { |
| | | confirmButtonText: 'ç¡®å®', |
| | | cancelButtonText: 'åæ¶', |
| | | type: 'warning' |
| | | }).then(() => { |
| | | return upReportUrl({ id: row.id }).then(res => { |
| | | if (res.code === 200) { |
| | | this.$message.success('å·²è¿åç³»ç»æ¥åï¼å¯ä»¥éæ°çæ') |
| | | this.refreshTable('page') |
| | | } |
| | | }) |
| | | }).catch(() => { |
| | | }) |
| | | }, |
| | | // å®¡æ ¸æé® |
| | | handleIssued(row) { |
| | | this.currentInfo = row; |
| | | let fileName = (row.urlS === null || row.urlS === '') ? row.url : row.urlS |
| | | let fileType = "docx" |
| | | if (row.tempUrlPdf != null || row.tempUrlPdf === '') { |
| | | fileName = row.tempUrlPdf |
| | | fileType = "pdf" |
| | | async handleIssued(row) { |
| | | const generation = ++this.auditOpenGeneration |
| | | this.currentInfo = row |
| | | this.auditEditSessions = {} |
| | | this.auditDocument = null |
| | | this.auditEditorReady = false |
| | | this.auditDirty = false |
| | | this.loadedAuditReportType = null |
| | | this.currentAuditReportType = 0 |
| | | this.auditOpening = true |
| | | this.auditOpenProgress = 5 |
| | | this.auditOpenText = 'æ£å¨ç¡®è®¤æ¥åçæ¬â¦' |
| | | this.auditOpenError = '' |
| | | this.issuedVisible = true |
| | | // 让ç¼è¾å¨èæ¬ä¸æ¥åçæ¬è¯·æ±åæ¶å è½½ï¼é¿å
ä¸è½½å® Word ååçå¾
èæ¬ã |
| | | loadSuperDoc().catch(() => {}) |
| | | try { |
| | | // 两份æ¥åé½ç¡®è®¤çæ¬ï¼æªæå¼çæ¥åæ²¿ç¨æå¡ç«¯æä¹
åçæ¬ã |
| | | const types = row.inspectionUrl ? [0, 1] : [0] |
| | | const configs = await Promise.all(types.map(type => getReportEditConfig(row.id, type))) |
| | | if (generation !== this.auditOpenGeneration || !this.issuedVisible) return |
| | | types.forEach((type, index) => { this.auditEditSessions[type] = configs[index].data }) |
| | | this.auditOpenProgress = 30 |
| | | await this.loadAuditEditor(0, generation) |
| | | } catch (e) { |
| | | if (generation !== this.auditOpenGeneration || !this.issuedVisible) return |
| | | this.auditOpening = false |
| | | this.auditOpenError = e.message || 'æ¥åæå¼å¤±è´¥ï¼è¯·éè¯' |
| | | } |
| | | fileName = fileName.replace('/word/', '') |
| | | const userName = this.nickName |
| | | this.option = { |
| | | url: this.javaApi + "/word/" + fileName, |
| | | isEdit: false, |
| | | fileType: fileType, |
| | | title: fileName, |
| | | lang: 'zh-CN', |
| | | isPrint: false, |
| | | user_id: 1, |
| | | user_name: userName, |
| | | editUrl: this.javaApi + "/insReport/onlyOffice/save?fileName=" + fileName |
| | | }, |
| | | async loadAuditEditor(reportType, generation = this.auditOpenGeneration) { |
| | | this.auditEditorReady = false |
| | | this.auditOpening = true |
| | | this.auditOpenProgress = Math.max(this.auditOpenProgress, 30) |
| | | const reportName = reportType === 0 ? 'æ£æµæ¥å' : 'æ£éªæ¥å' |
| | | this.auditOpenText = `æ£å¨ä¸è½½${reportName}â¦` |
| | | const config = this.auditEditSessions[reportType] |
| | | const blob = await getReportEditDocument(config.sessionToken, event => { |
| | | if (generation !== this.auditOpenGeneration || !this.issuedVisible) return |
| | | const ratio = event.total > 0 ? event.loaded / event.total : event.progress |
| | | if (typeof ratio === 'number' && Number.isFinite(ratio)) { |
| | | this.auditOpenProgress = Math.max(this.auditOpenProgress, 30 + Math.round(Math.min(1, ratio) * 45)) |
| | | this.auditOpenText = `æ£å¨ä¸è½½${reportName}ï¼${Math.round(Math.min(1, ratio) * 100)}%ï¼â¦` |
| | | } |
| | | }) |
| | | if (generation !== this.auditOpenGeneration || !this.issuedVisible) return false |
| | | if (!(blob instanceof Blob)) throw new Error('è¯»åæ¥å失败') |
| | | if (blob.type.includes('json')) { |
| | | const error = JSON.parse(await blob.text()) |
| | | throw new Error(error.msg || 'è¯»åæ¥å失败') |
| | | } |
| | | this.issuedVisible = true; |
| | | this.auditOpenProgress = 80 |
| | | this.auditOpenText = 'æ£å¨æç Word ææ¡£â¦' |
| | | this.auditDocument = { ...config, blob, code: this.currentInfo?.code } |
| | | this.loadedAuditReportType = reportType |
| | | this.currentAuditReportType = reportType |
| | | this.auditDirty = false |
| | | return true |
| | | }, |
| | | onAuditEditorReady() { |
| | | if (!this.issuedVisible || !this.auditDocument) return |
| | | this.auditEditorReady = true |
| | | this.auditOpenProgress = 100 |
| | | this.auditOpenText = 'æ¥åå·²æå¼' |
| | | this.auditOpenError = '' |
| | | this.auditOpening = false |
| | | }, |
| | | onAuditEditorError(error) { |
| | | if (!this.issuedVisible || !this.auditDocument) return |
| | | this.auditEditorReady = false |
| | | this.auditOpening = false |
| | | this.auditOpenError = error || 'Word ç¼è¾å¨å 载失败' |
| | | }, |
| | | async handleAuditReportTypeChange(reportType) { |
| | | const previous = this.loadedAuditReportType |
| | | if (Number(reportType) === Number(previous)) return |
| | | this.savingReport = true |
| | | this.auditOpening = true |
| | | this.auditOpenProgress = 10 |
| | | this.auditOpenText = 'æ£å¨ä¿åå½åæ¥åâ¦' |
| | | this.auditOpenError = '' |
| | | try { |
| | | await this.saveCurrentAuditReport() |
| | | await this.loadAuditEditor(Number(reportType)) |
| | | } catch (e) { |
| | | this.currentAuditReportType = previous |
| | | this.auditEditorReady = !!this.auditDocument |
| | | this.auditOpening = false |
| | | this.auditOpenError = '' |
| | | this.$message.error(e.message || 'æ¥åä¿åå¤±è´¥ï¼æ æ³åæ¢') |
| | | } finally { |
| | | this.savingReport = false |
| | | } |
| | | }, |
| | | async saveCurrentAuditReport() { |
| | | if (!this.auditEditorReady || !this.$refs.auditWordEditor) throw new Error('æ¥åå°æªå è½½å®æ') |
| | | const config = this.auditEditSessions[this.loadedAuditReportType] |
| | | const blob = await this.$refs.auditWordEditor.exportDocx() |
| | | const res = await saveReportEditDocument(config.sessionToken, config.version, blob) |
| | | config.version = res.data.version |
| | | this.auditDirty = false |
| | | }, |
| | | async saveAuditDraft() { |
| | | if (this.savingReport) return |
| | | this.savingReport = true |
| | | try { |
| | | await this.saveCurrentAuditReport() |
| | | this.$message.success('æ¥åå·²ä¿å') |
| | | this.refreshTable('page') |
| | | } catch (e) { |
| | | this.$message.error(e.message || 'æ¥åä¿å失败') |
| | | } finally { |
| | | this.savingReport = false |
| | | } |
| | | }, |
| | | async closeAuditEditor(done) { |
| | | if (this.savingReport || this.loadingIssued || this.addApproverDia) return |
| | | if (this.auditDirty) { |
| | | try { |
| | | await this.$confirm('æå°æªä¿åçç¼è¾ï¼å
³éå°ä¸¢å¼è¿äºä¿®æ¹ã确认å
³éï¼', 'å
³éå®¡æ ¸') |
| | | } catch (_) { return } |
| | | } |
| | | this.auditOpenGeneration++ |
| | | this.auditOpening = false |
| | | this.auditOpenError = '' |
| | | this.auditDocument = null |
| | | done() |
| | | }, |
| | | // æ¥çæ¥å |
| | | viewIssued(row) { |
| | |
| | | }); |
| | | }, |
| | | // å®¡æ ¸éè¿ |
| | | submitAddApprover() { |
| | | async submitAddApprover() { |
| | | if (!this.approver) { |
| | | this.$message.error('è¯·éæ©å®¡æ¹äºº') |
| | | return |
| | |
| | | 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('æäº¤æå') |
| | |
| | | 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) { |
| | |
| | | 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; |
| | |
| | | <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> |
| | |
| | | number: "", |
| | | name: "", |
| | | remark: "", |
| | | standardRequirement: "", |
| | | price: "", |
| | | id: "", |
| | | }, |
| | |
| | | { label: "æ£éªä¾æ®", prop: "number" }, |
| | | { label: "æ£éªé¡¹ç®", prop: "name" }, |
| | | { label: "夿³¨", prop: "remark" }, |
| | | { label: "æ åè¦æ±å¼", prop: "standardRequirement" }, |
| | | { label: "ä»·æ ¼(å
)", prop: "price" }, |
| | | { label: "åå»ºç¨æ·", prop: "createUserName" }, |
| | | { label: "å建æ¶é´", prop: "createTime", width: "160" }, |
| | |
| | | copyTemplate(row) { |
| | | this.title = "å¤å¶æ¨¡ç"; |
| | | this.isShowCopyTem = true; |
| | | this.copyForm.id = row.id; |
| | | this.copyForm = this.HaveJson(row); |
| | | }, |
| | | // æ°å¢/ç¼è¾/å¤å¶æ¨¡æ¿ |
| | | submitCopyInfo() { |
| | |
| | | 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) { |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | 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) |
| | | } |
| | | }) |