提交
This commit is contained in:
1 parent
d2290eb92c
commit
a2d4d3b0d5
320 files changed
+125011
-26951
No files matched your search
@@ -0,0 +1,316 @@
|
||||
<template>
|
||||
<div class="tinymce-editor-container">
|
||||
<div v-if="showPrint" class="tinymce-editor-extra-bar">
|
||||
<el-button size="small" @click="handlePrint">
|
||||
<el-icon><Printer /></el-icon>
|
||||
打印
|
||||
</el-button>
|
||||
</div>
|
||||
<Editor
|
||||
:id="editorId"
|
||||
v-model="contentValue"
|
||||
:init="editorInit"
|
||||
:disabled="disabled"
|
||||
tinymce-script-src="/tinymce/tinymce.min.js"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, computed } from 'vue'
|
||||
import Editor from '@tinymce/tinymce-vue'
|
||||
import { Printer } from '@element-plus/icons-vue'
|
||||
import { fetchUploadAttachment } from '@/api/business'
|
||||
import {
|
||||
printSopHtml,
|
||||
setupSopEditorInteractions,
|
||||
SOP_EDITOR_CONTENT_STYLE,
|
||||
getSopA4EditorHeightPx,
|
||||
resolveSopPageCount
|
||||
} from '@/utils/sop-print'
|
||||
|
||||
defineOptions({ name: 'TinyMceEditor' })
|
||||
|
||||
interface Props {
|
||||
modelValue?: string
|
||||
disabled?: boolean
|
||||
height?: number
|
||||
toolbar?: boolean
|
||||
placeholder?: string
|
||||
/** 附件上传 folder,与门户附件接口一致 */
|
||||
uploadFolder?: string
|
||||
/** 是否显示 SOP 打印按钮 */
|
||||
showPrint?: boolean
|
||||
/** 打印页标题 */
|
||||
printTitle?: string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
modelValue: '',
|
||||
disabled: false,
|
||||
height: 400,
|
||||
toolbar: true,
|
||||
placeholder: '请输入内容...',
|
||||
uploadFolder: 'production/process-sop',
|
||||
showPrint: false,
|
||||
printTitle: 'SOP'
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: string): void
|
||||
}>()
|
||||
|
||||
const TINYMCE_Z_INDEX_FLOOR = 10000
|
||||
|
||||
const resolveTinyMceBaseZIndex = (): number => {
|
||||
let maxZ = 3000
|
||||
document.querySelectorAll('.el-overlay, .el-dialog, .el-popper').forEach((el) => {
|
||||
const z = Number.parseInt(window.getComputedStyle(el).zIndex || '0', 10)
|
||||
if (!Number.isNaN(z) && z > maxZ) maxZ = z
|
||||
})
|
||||
return Math.max(maxZ + 200, TINYMCE_Z_INDEX_FLOOR)
|
||||
}
|
||||
|
||||
const syncTinyMceFloatingZIndex = (baseZ: number) => {
|
||||
const z = String(baseZ)
|
||||
const backdropZ = String(baseZ - 1)
|
||||
document.querySelectorAll('.tox-tinymce-aux').forEach((el) => {
|
||||
;(el as HTMLElement).style.zIndex = z
|
||||
})
|
||||
document.querySelectorAll('.tox-dialog-wrap').forEach((el) => {
|
||||
;(el as HTMLElement).style.zIndex = z
|
||||
})
|
||||
document.querySelectorAll('.tox-dialog').forEach((el) => {
|
||||
;(el as HTMLElement).style.zIndex = String(baseZ + 1)
|
||||
})
|
||||
document.querySelectorAll('.tox-dialog__backdrop').forEach((el) => {
|
||||
;(el as HTMLElement).style.zIndex = backdropZ
|
||||
})
|
||||
document.querySelectorAll('.tox-menu, .tox-pop, .tox-toolbar__overflow, .tox-collection').forEach(
|
||||
(el) => {
|
||||
;(el as HTMLElement).style.zIndex = String(baseZ + 2)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const editorId = `tinymce-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`
|
||||
const contentValue = ref(props.modelValue)
|
||||
const editorInstance = ref<{
|
||||
getContent: (args?: { format?: string }) => string
|
||||
} | null>(null)
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(newVal) => {
|
||||
if (newVal !== contentValue.value) {
|
||||
contentValue.value = newVal
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
watch(contentValue, (newVal) => {
|
||||
emit('update:modelValue', newVal)
|
||||
})
|
||||
|
||||
async function handlePrint() {
|
||||
// 从编辑器实时取 HTML,避免勾选态只在 iframe DOM 中、v-model 未同步
|
||||
const liveHtml = editorInstance.value?.getContent() || contentValue.value || props.modelValue || ''
|
||||
if (editorInstance.value) {
|
||||
contentValue.value = liveHtml
|
||||
}
|
||||
await printSopHtml(liveHtml, props.printTitle)
|
||||
}
|
||||
|
||||
const useSopA4Layout = computed(() => props.showPrint)
|
||||
|
||||
const sopPageCount = computed(() =>
|
||||
useSopA4Layout.value
|
||||
? resolveSopPageCount(contentValue.value || props.modelValue || '')
|
||||
: 1
|
||||
)
|
||||
|
||||
const editorInit = computed(() => {
|
||||
const baseZIndex = resolveTinyMceBaseZIndex()
|
||||
const sopA4 = useSopA4Layout.value
|
||||
|
||||
return {
|
||||
skin_url: '/tinymce/skins/ui/oxide',
|
||||
language: 'zh_CN',
|
||||
language_url: '/tinymce/langs/zh_CN.js',
|
||||
height: sopA4 ? getSopA4EditorHeightPx(sopPageCount.value) : props.height,
|
||||
resize: !sopA4,
|
||||
menubar: false,
|
||||
statusbar: false,
|
||||
readonly: props.disabled,
|
||||
placeholder: props.placeholder,
|
||||
plugins: props.toolbar
|
||||
? [
|
||||
'advlist',
|
||||
'autolink',
|
||||
'lists',
|
||||
'link',
|
||||
'image',
|
||||
'charmap',
|
||||
'searchreplace',
|
||||
'visualblocks',
|
||||
'code',
|
||||
'fullscreen',
|
||||
'insertdatetime',
|
||||
'table',
|
||||
'wordcount'
|
||||
]
|
||||
: [],
|
||||
toolbar: props.toolbar
|
||||
? 'undo redo | bold italic underline | alignleft aligncenter alignright | bullist numlist | table | link image | forecolor backcolor | removeformat | code fullscreen'
|
||||
: false,
|
||||
content_style: sopA4
|
||||
? SOP_EDITOR_CONTENT_STYLE
|
||||
: `
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB',
|
||||
'Microsoft YaHei', sans-serif;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
color: #334155;
|
||||
padding: 16px;
|
||||
}
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
table td,
|
||||
table th {
|
||||
border: 1px solid #e2e8f0;
|
||||
padding: 8px;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
table th {
|
||||
background-color: #f8fafc;
|
||||
font-weight: 600;
|
||||
}
|
||||
`,
|
||||
extended_valid_elements: '*[*]',
|
||||
valid_elements: '*[*]',
|
||||
table_toolbar:
|
||||
'tableprops tablecellprops tabledelete | tableinsertrowbefore tableinsertrowafter tabledeleterow | tableinsertcolbefore tableinsertcolafter tabledeletecol',
|
||||
table_appearance_options: true,
|
||||
table_grid: true,
|
||||
table_resize_bars: true,
|
||||
branding: false,
|
||||
promotion: false,
|
||||
base_z_index: baseZIndex,
|
||||
images_upload_handler: (blobInfo: { blob: () => Blob; filename: () => string }, progress: (n: number) => void) => {
|
||||
return new Promise<string>(async (resolve, reject) => {
|
||||
try {
|
||||
progress(10)
|
||||
const file = new File([blobInfo.blob()], blobInfo.filename(), {
|
||||
type: blobInfo.blob().type || 'image/jpeg'
|
||||
})
|
||||
const formData = new FormData()
|
||||
formData.append('folder', props.uploadFolder)
|
||||
formData.append('file', file)
|
||||
progress(40)
|
||||
const res = await fetchUploadAttachment(formData)
|
||||
const fileUrl = res?.fileUrl
|
||||
if (!fileUrl) {
|
||||
reject('上传失败')
|
||||
return
|
||||
}
|
||||
progress(100)
|
||||
resolve(fileUrl)
|
||||
} catch (error: unknown) {
|
||||
const err = error as { message?: string }
|
||||
reject(err?.message || '上传失败')
|
||||
}
|
||||
})
|
||||
},
|
||||
paste_data_images: true,
|
||||
automatic_uploads: true,
|
||||
file_picker_types: 'image',
|
||||
images_file_types: 'jpg,jpeg,png,gif,bmp,webp',
|
||||
image_advtab: true,
|
||||
setup: (editor: {
|
||||
on: (name: string, fn: ((e?: Event) => void) | (() => void)) => void
|
||||
getContainer: () => HTMLElement
|
||||
getBody: () => HTMLElement
|
||||
getContent: (args?: { format?: string }) => string
|
||||
undoManager: { transact: (fn: () => void) => void }
|
||||
nodeChanged: () => void
|
||||
fire: (name: string) => void
|
||||
setDirty?: (state: boolean) => void
|
||||
}) => {
|
||||
const refreshFloatingLayers = () => {
|
||||
const z = resolveTinyMceBaseZIndex()
|
||||
syncTinyMceFloatingZIndex(z)
|
||||
}
|
||||
|
||||
editor.on('init', refreshFloatingLayers)
|
||||
editor.on('OpenWindow', refreshFloatingLayers)
|
||||
editor.on('CloseWindow', refreshFloatingLayers)
|
||||
editor.on('ShowMenu', refreshFloatingLayers)
|
||||
editor.on('focus', refreshFloatingLayers)
|
||||
|
||||
editor.on('init', () => {
|
||||
editorInstance.value = editor as { getContent: (args?: { format?: string }) => string }
|
||||
if (props.disabled) {
|
||||
const toolbar = editor.getContainer().querySelector('.tox-toolbar')
|
||||
if (toolbar) {
|
||||
toolbar.style.display = 'none'
|
||||
}
|
||||
}
|
||||
if (sopA4) {
|
||||
setupSopEditorInteractions(editor)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.tinymce-editor-container {
|
||||
:deep(.tox-tinymce) {
|
||||
border: 1px solid #dcdfe6;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
:deep(.tox-editor-header) {
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
}
|
||||
}
|
||||
|
||||
.tinymce-editor-extra-bar {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="scss">
|
||||
.tox-tinymce-aux {
|
||||
z-index: 10000 !important;
|
||||
}
|
||||
|
||||
.tox-dialog-wrap {
|
||||
z-index: 10000 !important;
|
||||
}
|
||||
|
||||
.tox-dialog {
|
||||
z-index: 10001 !important;
|
||||
}
|
||||
|
||||
.tox-dialog__backdrop {
|
||||
z-index: 9999 !important;
|
||||
}
|
||||
|
||||
.tox-menu,
|
||||
.tox-pop,
|
||||
.tox-toolbar__overflow,
|
||||
.tox-collection,
|
||||
.tox-swatches,
|
||||
.tox-selector {
|
||||
z-index: 10002 !important;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,574 @@
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
export const SOP_A4_WIDTH_MM = 210
|
||||
|
||||
/** A4 竖版页高 (mm) */
|
||||
export const SOP_A4_HEIGHT_MM = 297
|
||||
|
||||
/** 编辑区在 A4 基础上额外增加的高度 (px) */
|
||||
export const SOP_EDITOR_EXTRA_HEIGHT_PX = 200
|
||||
|
||||
/** 从 SOP HTML 解析页数(class 含 sop-a4-pages-N) */
|
||||
export function resolveSopPageCount(html: string): number {
|
||||
const m = html?.match(/sop-a4-pages-(\d+)/)
|
||||
if (!m) return 1
|
||||
const n = Number.parseInt(m[1], 10)
|
||||
return Number.isFinite(n) && n > 0 ? n : 1
|
||||
}
|
||||
|
||||
/** TinyMCE 正文区高度 (px, 96dpi)。init.height 不含工具栏 */
|
||||
export function getSopA4ContentHeightPx(pages = 1): number {
|
||||
const p = Math.max(1, pages)
|
||||
return Math.ceil((SOP_A4_HEIGHT_MM * p * 96) / 25.4)
|
||||
}
|
||||
|
||||
/** TinyMCE 编辑区高度:A4×页数 + 额外空间 */
|
||||
export function getSopA4EditorHeightPx(pages = 1): number {
|
||||
return getSopA4ContentHeightPx(pages) + SOP_EDITOR_EXTRA_HEIGHT_PX
|
||||
}
|
||||
|
||||
const SOP_SHARED_CONTENT_STYLE = `
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
table td,
|
||||
table th {
|
||||
border: 1px solid #e2e8f0;
|
||||
padding: 8px;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
table th {
|
||||
background-color: #f8fafc;
|
||||
font-weight: 600;
|
||||
}
|
||||
.sop-record.sop-a4 {
|
||||
box-sizing: border-box;
|
||||
max-width: 210mm;
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
padding: 16mm 15mm 20mm;
|
||||
background: #fff;
|
||||
font-size: 10.5pt;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.sop-record.sop-a4 table {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
/* 嵌套比例表:去掉 TinyMCE 无边框表虚线辅助框,仅保留标签/选项分隔线 */
|
||||
.sop-nested-table,
|
||||
.sop-nested-table.mce-item-table,
|
||||
.sop-nested-table.mce-item-table:not([border]),
|
||||
.sop-nested-table.mce-item-table[border='0'],
|
||||
.sop-nested-table.mce-item-table[border='1'] {
|
||||
border: none !important;
|
||||
margin: 0 !important;
|
||||
}
|
||||
.sop-nested-table td,
|
||||
.sop-nested-table th,
|
||||
.sop-nested-table.mce-item-table:not([border]) td,
|
||||
.sop-nested-table.mce-item-table[border='0'] td,
|
||||
.sop-nested-table.mce-item-table[border='1'] td,
|
||||
.sop-nested-table.mce-item-table:not([border]) th,
|
||||
.sop-nested-table.mce-item-table[border='0'] th,
|
||||
.sop-nested-table.mce-item-table[border='1'] th {
|
||||
border: none !important;
|
||||
}
|
||||
.sop-nested-table > tbody > tr > td:not(:last-child) {
|
||||
border-right: 1px solid #334155 !important;
|
||||
}
|
||||
/* 补液等业务嵌套表:实线边框(非虚线辅助框) */
|
||||
.sop-grid-table,
|
||||
.sop-grid-table.mce-item-table,
|
||||
.sop-grid-table.mce-item-table:not([border]),
|
||||
.sop-grid-table.mce-item-table[border='0'],
|
||||
.sop-grid-table.mce-item-table[border='1'] {
|
||||
width: 100% !important;
|
||||
border-collapse: collapse !important;
|
||||
table-layout: fixed !important;
|
||||
margin: 8px 0 !important;
|
||||
border: 1px solid #334155 !important;
|
||||
}
|
||||
.sop-grid-table td,
|
||||
.sop-grid-table th,
|
||||
.sop-grid-table.mce-item-table:not([border]) td,
|
||||
.sop-grid-table.mce-item-table[border='0'] td,
|
||||
.sop-grid-table.mce-item-table[border='1'] td,
|
||||
.sop-grid-table.mce-item-table:not([border]) th,
|
||||
.sop-grid-table.mce-item-table[border='0'] th,
|
||||
.sop-grid-table.mce-item-table[border='1'] th {
|
||||
border: 1px solid #334155 !important;
|
||||
padding: 4px 3px !important;
|
||||
font-size: 9pt;
|
||||
vertical-align: middle;
|
||||
word-break: break-word;
|
||||
}
|
||||
.sop-header-table {
|
||||
font-size: 12pt;
|
||||
flex-shrink: 0;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.sop-body-table {
|
||||
flex: 1 1 auto;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
.sop-body-table tbody {
|
||||
height: 100%;
|
||||
}
|
||||
.sop-body-table tr.sop-fill-row {
|
||||
height: 100%;
|
||||
}
|
||||
.sop-body-table td.sop-fill-cell,
|
||||
.sop-body-table td.sop-fill-label {
|
||||
height: 100%;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.sop-header-table td {
|
||||
border-color: #334155;
|
||||
}
|
||||
.sop-option {
|
||||
display: inline-flex;
|
||||
align-items: flex-end;
|
||||
gap: 4px;
|
||||
margin-right: 10px;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
vertical-align: text-bottom;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.sop-option.sop-option-wrap {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
align-items: flex-start;
|
||||
white-space: normal;
|
||||
max-width: 100%;
|
||||
margin-right: 0;
|
||||
vertical-align: top;
|
||||
}
|
||||
.sop-option.sop-option-wrap .sop-check {
|
||||
flex-shrink: 0;
|
||||
margin-top: 2px;
|
||||
top: 0;
|
||||
}
|
||||
.sop-option.sop-option-wrap .sop-option-text {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
white-space: normal;
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
.sop-option-cell .sop-option:last-child {
|
||||
margin-right: 0;
|
||||
}
|
||||
.sop-option-cell-wide .sop-option {
|
||||
margin-right: 6px;
|
||||
}
|
||||
.sop-body-table tbody td {
|
||||
text-align: left;
|
||||
}
|
||||
.sop-body-table tbody td[style*='text-align:center'],
|
||||
.sop-body-table tbody td.sop-fill-label,
|
||||
.sop-body-table tbody td.sop-option-cell {
|
||||
text-align: center;
|
||||
}
|
||||
.sop-underline,
|
||||
.sop-record span[style*='border-bottom:1px solid #334155'],
|
||||
.sop-record span[style*='border-bottom: 1px solid #334155'] {
|
||||
display: inline-block;
|
||||
border-bottom: 1px solid #334155;
|
||||
text-align: left !important;
|
||||
vertical-align: text-bottom;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.sop-blank-cell {
|
||||
text-align: left !important;
|
||||
vertical-align: top;
|
||||
}
|
||||
.sop-check {
|
||||
display: inline-block;
|
||||
flex-shrink: 0;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border: 1.5px solid #334155;
|
||||
border-radius: 2px;
|
||||
box-sizing: border-box;
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
/* 避免内部 把基线抬到方框中部;略上移与汉字底边视觉对齐 */
|
||||
font-size: 0;
|
||||
line-height: 0;
|
||||
overflow: hidden;
|
||||
vertical-align: text-bottom;
|
||||
top: -3px;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
.sop-check--checked {
|
||||
background: #334155;
|
||||
-webkit-print-color-adjust: exact;
|
||||
print-color-adjust: exact;
|
||||
}
|
||||
.sop-check--checked::after,
|
||||
.sop-check[data-sop-checked='1']::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 2px;
|
||||
top: 1px;
|
||||
width: 8px;
|
||||
height: 5px;
|
||||
border-left: 2px solid #fff;
|
||||
border-bottom: 2px solid #fff;
|
||||
transform: rotate(-45deg);
|
||||
}
|
||||
.sop-check[data-sop-checked='1'] {
|
||||
background: #334155;
|
||||
}
|
||||
`
|
||||
|
||||
const editorHeightPx = getSopA4EditorHeightPx(1)
|
||||
const editorHeightPx2 = getSopA4EditorHeightPx(2)
|
||||
const printHeight2Mm = SOP_A4_HEIGHT_MM * 2
|
||||
|
||||
/** TinyMCE 编辑区:A4 + 额外高度,无内边距裁切 */
|
||||
export const SOP_EDITOR_CONTENT_STYLE = `
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB',
|
||||
'Microsoft YaHei', sans-serif;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
color: #334155;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
background: #fff;
|
||||
}
|
||||
${SOP_SHARED_CONTENT_STYLE}
|
||||
.sop-record.sop-a4 {
|
||||
min-height: ${editorHeightPx}px;
|
||||
height: ${editorHeightPx}px;
|
||||
}
|
||||
.sop-record.sop-a4.sop-a4-pages-2 {
|
||||
min-height: ${editorHeightPx2}px;
|
||||
height: auto;
|
||||
}
|
||||
.sop-record.sop-a4.sop-a4-pages-2 .sop-body-table,
|
||||
.sop-record.sop-a4.sop-a4-pages-2 .sop-body-table tbody {
|
||||
flex: 0 1 auto !important;
|
||||
height: auto !important;
|
||||
}
|
||||
`
|
||||
|
||||
/** 打印页样式 */
|
||||
export const SOP_PRINT_CONTENT_STYLE = `
|
||||
html {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB',
|
||||
'Microsoft YaHei', sans-serif;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
color: #334155;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
background: #fff;
|
||||
}
|
||||
${SOP_SHARED_CONTENT_STYLE}
|
||||
.sop-record.sop-a4 {
|
||||
height: ${SOP_A4_HEIGHT_MM}mm;
|
||||
min-height: ${SOP_A4_HEIGHT_MM}mm;
|
||||
max-height: ${SOP_A4_HEIGHT_MM}mm;
|
||||
page-break-after: avoid;
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
.sop-record.sop-a4.sop-a4-pages-2 {
|
||||
height: auto;
|
||||
min-height: ${printHeight2Mm}mm;
|
||||
max-height: none;
|
||||
page-break-after: auto;
|
||||
page-break-inside: auto;
|
||||
}
|
||||
@media print {
|
||||
@page {
|
||||
size: A4 portrait;
|
||||
margin: 0;
|
||||
}
|
||||
html,
|
||||
body {
|
||||
width: ${SOP_A4_WIDTH_MM}mm;
|
||||
height: auto;
|
||||
min-height: ${SOP_A4_HEIGHT_MM}mm;
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
overflow: visible;
|
||||
}
|
||||
.sop-record.sop-a4 {
|
||||
max-width: none;
|
||||
width: auto;
|
||||
margin: 0;
|
||||
padding: 16mm 15mm 20mm;
|
||||
height: ${SOP_A4_HEIGHT_MM}mm;
|
||||
min-height: ${SOP_A4_HEIGHT_MM}mm;
|
||||
max-height: ${SOP_A4_HEIGHT_MM}mm;
|
||||
}
|
||||
.sop-record.sop-a4.sop-a4-pages-2 {
|
||||
height: auto;
|
||||
min-height: ${printHeight2Mm}mm;
|
||||
max-height: none;
|
||||
page-break-inside: auto;
|
||||
}
|
||||
.sop-check {
|
||||
width: 3.5mm;
|
||||
height: 3.5mm;
|
||||
border-width: 0.35mm;
|
||||
top: -0.4mm;
|
||||
-webkit-print-color-adjust: exact !important;
|
||||
print-color-adjust: exact !important;
|
||||
}
|
||||
/* 打印用白底黑勾,不依赖“背景图形”选项 */
|
||||
.sop-check--checked,
|
||||
.sop-check[data-sop-checked='1'] {
|
||||
background: #fff !important;
|
||||
-webkit-print-color-adjust: exact !important;
|
||||
print-color-adjust: exact !important;
|
||||
}
|
||||
.sop-check--checked::after,
|
||||
.sop-check[data-sop-checked='1']::after {
|
||||
border-left-color: #000 !important;
|
||||
border-bottom-color: #000 !important;
|
||||
left: 0.5mm;
|
||||
top: 0.2mm;
|
||||
width: 2mm;
|
||||
height: 1.2mm;
|
||||
border-left-width: 0.4mm;
|
||||
border-bottom-width: 0.4mm;
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
type SopTinyMceEditor = {
|
||||
on: (name: string, fn: (e: Event) => void) => void
|
||||
getBody: () => HTMLElement
|
||||
getContent: (args?: { format?: string }) => string
|
||||
undoManager: { transact: (fn: () => void) => void }
|
||||
nodeChanged: () => void
|
||||
fire: (name: string) => void
|
||||
setDirty?: (state: boolean) => void
|
||||
}
|
||||
|
||||
/** 规范化勾选框:避免 TinyMCE 清掉空 span,并禁止直接编辑 */
|
||||
export function normalizeSopCheckboxes(root: ParentNode | null | undefined): void {
|
||||
if (!root) return
|
||||
root.querySelectorAll('.sop-check').forEach((el) => {
|
||||
const node = el as HTMLElement
|
||||
node.setAttribute('contenteditable', 'false')
|
||||
node.setAttribute('data-mce-contenteditable', 'false')
|
||||
if (!node.textContent || !node.textContent.trim()) {
|
||||
node.innerHTML = ' '
|
||||
}
|
||||
// 用属性兜底勾选态,便于序列化与打印
|
||||
if (node.classList.contains('sop-check--checked')) {
|
||||
node.setAttribute('data-sop-checked', '1')
|
||||
} else if (node.getAttribute('data-sop-checked') === '1') {
|
||||
node.classList.add('sop-check--checked')
|
||||
} else {
|
||||
node.removeAttribute('data-sop-checked')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** SOP 富文本编辑区:勾选框点击切换、选项行可点选 */
|
||||
export function setupSopEditorInteractions(editor: SopTinyMceEditor): void {
|
||||
const resolveCheckEl = (target: HTMLElement | null): HTMLElement | null => {
|
||||
if (!target) return null
|
||||
if (target.classList.contains('sop-check')) return target
|
||||
const option = target.closest('.sop-option')
|
||||
if (option) return option.querySelector('.sop-check')
|
||||
return null
|
||||
}
|
||||
|
||||
const toggleCheck = (checkEl: HTMLElement) => {
|
||||
editor.undoManager.transact(() => {
|
||||
normalizeSopCheckboxes(checkEl.parentElement || editor.getBody())
|
||||
const checked = checkEl.classList.toggle('sop-check--checked')
|
||||
if (checked) {
|
||||
checkEl.setAttribute('data-sop-checked', '1')
|
||||
} else {
|
||||
checkEl.removeAttribute('data-sop-checked')
|
||||
}
|
||||
})
|
||||
editor.nodeChanged()
|
||||
editor.setDirty?.(true)
|
||||
// change + input,确保 tinymce-vue v-model 同步勾选后的 HTML
|
||||
editor.fire('change')
|
||||
editor.fire('input')
|
||||
}
|
||||
|
||||
const handleSopClick = (e: Event) => {
|
||||
const target = e.target as HTMLElement | null
|
||||
const checkEl = resolveCheckEl(target)
|
||||
if (!checkEl) return
|
||||
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
toggleCheck(checkEl)
|
||||
}
|
||||
|
||||
// 仅绑定 click,避免 mousedown+click 双触发导致勾选被立刻取消
|
||||
editor.on('click', handleSopClick)
|
||||
editor.on('SetContent', () => {
|
||||
normalizeSopCheckboxes(editor.getBody())
|
||||
})
|
||||
normalizeSopCheckboxes(editor.getBody())
|
||||
}
|
||||
|
||||
|
||||
function prepareSopHtmlForPrint(html: string): string {
|
||||
const doc = new DOMParser().parseFromString(html, 'text/html')
|
||||
doc.querySelectorAll('.sop-check').forEach((el) => {
|
||||
const node = el as HTMLElement
|
||||
const checked =
|
||||
node.classList.contains('sop-check--checked') || node.getAttribute('data-sop-checked') === '1'
|
||||
if (checked) {
|
||||
node.classList.add('sop-check--checked')
|
||||
node.setAttribute('data-sop-checked', '1')
|
||||
} else {
|
||||
node.classList.remove('sop-check--checked')
|
||||
node.removeAttribute('data-sop-checked')
|
||||
}
|
||||
})
|
||||
return doc.body.innerHTML
|
||||
}
|
||||
|
||||
function resolveSopHtmlAssetUrls(html: string): string {
|
||||
const origin = window.location.origin
|
||||
return html.replace(/src="\/([^"]+)"/g, `src="${origin}/$1"`)
|
||||
}
|
||||
|
||||
function readBlobAsDataUrl(blob: Blob): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => resolve(String(reader.result || ''))
|
||||
reader.onerror = () => reject(reader.error || new Error('read failed'))
|
||||
reader.readAsDataURL(blob)
|
||||
})
|
||||
}
|
||||
|
||||
/** 将图片转为 data URL 嵌入,避免 blob 打印页无法加载顶部 logo */
|
||||
async function embedSopImagesAsDataUrls(html: string): Promise<string> {
|
||||
const doc = new DOMParser().parseFromString(html, 'text/html')
|
||||
const images = Array.from(doc.querySelectorAll('img'))
|
||||
await Promise.all(
|
||||
images.map(async (img) => {
|
||||
const src = (img.getAttribute('src') || '').trim()
|
||||
if (!src || src.startsWith('data:')) return
|
||||
|
||||
let absolute = src
|
||||
if (src.startsWith('/')) {
|
||||
absolute = `${window.location.origin}${src}`
|
||||
} else if (!/^https?:\/\//i.test(src)) {
|
||||
absolute = new URL(src, window.location.href).href
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(absolute, { credentials: 'same-origin' })
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
const blob = await res.blob()
|
||||
const dataUrl = await readBlobAsDataUrl(blob)
|
||||
if (dataUrl) {
|
||||
img.setAttribute('src', dataUrl)
|
||||
}
|
||||
} catch {
|
||||
img.setAttribute('src', absolute)
|
||||
}
|
||||
})
|
||||
)
|
||||
return doc.body.innerHTML
|
||||
}
|
||||
|
||||
function buildPrintDocumentHtml(bodyHtml: string): string {
|
||||
return `<!DOCTYPE html><html lang="zh-CN"><head><meta charset="utf-8"><title></title><style>${SOP_PRINT_CONTENT_STYLE}</style></head><body>${bodyHtml}</body></html>`
|
||||
}
|
||||
|
||||
function cleanupPrintIframe(iframe: HTMLIFrameElement, blobUrl?: string) {
|
||||
if (iframe.parentNode) {
|
||||
iframe.parentNode.removeChild(iframe)
|
||||
}
|
||||
if (blobUrl) {
|
||||
URL.revokeObjectURL(blobUrl)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 打印 SOP 富文本(独立打印页,空标题 + blob 页减少浏览器默认页眉页脚)
|
||||
*/
|
||||
export async function printSopHtml(html: string, _title = 'SOP'): Promise<void> {
|
||||
const content = String(html || '').trim()
|
||||
if (!content) {
|
||||
ElMessage.warning('暂无 SOP 内容可打印')
|
||||
return
|
||||
}
|
||||
|
||||
const preparedHtml = prepareSopHtmlForPrint(content)
|
||||
const resolvedHtml = resolveSopHtmlAssetUrls(preparedHtml)
|
||||
const embeddedHtml = await embedSopImagesAsDataUrls(resolvedHtml)
|
||||
const printHtml = buildPrintDocumentHtml(embeddedHtml)
|
||||
const blob = new Blob([printHtml], { type: 'text/html' })
|
||||
const blobUrl = URL.createObjectURL(blob)
|
||||
|
||||
const iframe = document.createElement('iframe')
|
||||
iframe.setAttribute('aria-hidden', 'true')
|
||||
iframe.style.cssText = 'position:fixed;right:0;bottom:0;width:0;height:0;border:none;'
|
||||
document.body.appendChild(iframe)
|
||||
|
||||
const win = iframe.contentWindow
|
||||
if (!win) {
|
||||
cleanupPrintIframe(iframe, blobUrl)
|
||||
ElMessage.error('无法创建打印页面')
|
||||
return
|
||||
}
|
||||
|
||||
const triggerPrint = () => {
|
||||
win.focus()
|
||||
win.print()
|
||||
setTimeout(() => cleanupPrintIframe(iframe, blobUrl), 1000)
|
||||
}
|
||||
|
||||
iframe.onload = () => {
|
||||
const doc = win.document
|
||||
const images = Array.from(doc?.querySelectorAll('img') ?? [])
|
||||
if (!images.length) {
|
||||
triggerPrint()
|
||||
return
|
||||
}
|
||||
|
||||
let settled = 0
|
||||
const onImageDone = () => {
|
||||
settled += 1
|
||||
if (settled >= images.length) {
|
||||
requestAnimationFrame(() => triggerPrint())
|
||||
}
|
||||
}
|
||||
|
||||
images.forEach((img) => {
|
||||
if (img.complete) {
|
||||
onImageDone()
|
||||
} else {
|
||||
img.addEventListener('load', onImageDone)
|
||||
img.addEventListener('error', onImageDone)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
iframe.onerror = () => {
|
||||
cleanupPrintIframe(iframe, blobUrl)
|
||||
ElMessage.error('无法加载打印页面')
|
||||
}
|
||||
|
||||
iframe.src = blobUrl
|
||||
}
|
||||
@@ -995,6 +995,18 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="selectedStep" class="route-config-step-section step-sop-content-section">
|
||||
<h3 class="route-config-step-section-title">SOP信息</h3>
|
||||
<TinyMceEditor
|
||||
:key="selectedStep.id"
|
||||
v-model="selectedStep.sopContent"
|
||||
show-print
|
||||
:print-title="selectedStep.processName || selectedStep.stepName || 'SOP'"
|
||||
placeholder="工序 SOP 说明(从工艺库拷贝后可在此调整)"
|
||||
@update:model-value="onSopContentChange"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<!-- 数据采集(SHOW_PROCESS_DATA_COLLECTION 控制展示) -->
|
||||
<section v-if="SHOW_PROCESS_DATA_COLLECTION" class="route-config-step-section">
|
||||
<h3 class="route-config-step-section-title">数据采集</h3>
|
||||
@@ -1134,6 +1146,7 @@
|
||||
Search
|
||||
} from '@element-plus/icons-vue'
|
||||
import ProtocolAttachmentReadonly from '@/views/cell-storage/protocol/ProtocolAttachmentReadonly.vue'
|
||||
import TinyMceEditor from '@/components/TinyMceEditor/index.vue'
|
||||
import TaskEquipmentPanel from '@/views/production/production-work-order/TaskEquipmentPanel.vue'
|
||||
import { downloadAttachmentFile } from '@/utils/download-attachment'
|
||||
import { parseEquipmentIds } from '@/utils/equipment-select'
|
||||
@@ -1414,6 +1427,7 @@
|
||||
function normalizeStepSubTables(step: Record<string, any>) {
|
||||
return {
|
||||
...step,
|
||||
sopContent: step.sopContent || '',
|
||||
reagents: (step.reagents || []).map((r: Record<string, any>) => ({
|
||||
...r,
|
||||
isKeyMaterial: fromApiFlag(r.isKeyMaterial)
|
||||
@@ -1777,6 +1791,15 @@
|
||||
saveStepBasic()
|
||||
}
|
||||
|
||||
let sopContentSaveTimer: ReturnType<typeof setTimeout> | null = null
|
||||
function onSopContentChange() {
|
||||
if (sopContentSaveTimer) clearTimeout(sopContentSaveTimer)
|
||||
sopContentSaveTimer = setTimeout(() => {
|
||||
sopContentSaveTimer = null
|
||||
void saveStepBasic()
|
||||
}, 800)
|
||||
}
|
||||
|
||||
const minioBaseUrl = (import.meta.env.VITE_MINIO_BASE_URL || '').replace(/\/$/, '')
|
||||
|
||||
function sopAttachmentFullUrl(url: string) {
|
||||
@@ -2079,7 +2102,8 @@
|
||||
categoryId: selectedStep.value.categoryId,
|
||||
description: selectedStep.value.description,
|
||||
estimatedDuration: selectedStep.value.estimatedDuration,
|
||||
sopIds: selectedStep.value.sopIds
|
||||
sopIds: selectedStep.value.sopIds,
|
||||
sopContent: selectedStep.value.sopContent
|
||||
})
|
||||
} catch {
|
||||
/* ignore */
|
||||
@@ -2822,6 +2846,10 @@
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.step-sop-content-section {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.step-sop-attachment-row {
|
||||
margin-top: -4px;
|
||||
|
||||
|
||||
@@ -176,7 +176,7 @@
|
||||
<div class="section-title">
|
||||
<div class="section-title-left"><h3>基本信息</h3></div>
|
||||
</div>
|
||||
<div class="form-grid">
|
||||
<div class="form-grid-4 process-basic-info-grid">
|
||||
<el-form-item label="工序代码" prop="processCode">
|
||||
<el-input v-model="formData.processCode" placeholder="请输入工序代码" />
|
||||
</el-form-item>
|
||||
@@ -199,11 +199,16 @@
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="SHOW_PROCESS_DURATION_DESC_SOP">
|
||||
<el-form-item label="备注">
|
||||
<el-input v-model="formData.remark" placeholder="可选" maxlength="512" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
<div v-if="SHOW_PROCESS_DURATION_DESC_SOP" class="form-grid process-basic-extra-grid">
|
||||
<el-form-item>
|
||||
<template #label>预估耗时<span class="form-label-tip">(h)</span></template>
|
||||
<el-input v-model="formData.estimatedDuration" placeholder="如 0.5" />
|
||||
</el-form-item>
|
||||
<el-form-item v-if="SHOW_PROCESS_DURATION_DESC_SOP" label="关联SOP">
|
||||
<el-form-item label="关联SOP">
|
||||
<el-select
|
||||
v-model="formData.sopIds"
|
||||
placeholder="请选择SOP文档"
|
||||
@@ -220,7 +225,7 @@
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="SHOW_PROCESS_DURATION_DESC_SOP" label="描述">
|
||||
<el-form-item label="描述" class="form-col-span-2">
|
||||
<el-input
|
||||
v-model="formData.description"
|
||||
type="textarea"
|
||||
@@ -228,14 +233,6 @@
|
||||
placeholder="请输入描述"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="备注">
|
||||
<el-input
|
||||
v-model="formData.remark"
|
||||
type="textarea"
|
||||
:rows="2"
|
||||
placeholder="可选"
|
||||
/>
|
||||
</el-form-item>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -559,6 +556,21 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="form-section-sop" class="form-tab-pane">
|
||||
<div class="section-card">
|
||||
<div class="section-title">
|
||||
<div class="section-title-left"><h3>SOP信息</h3></div>
|
||||
</div>
|
||||
<TinyMceEditor
|
||||
:key="sopEditorKey"
|
||||
v-model="formData.sopContent"
|
||||
show-print
|
||||
:print-title="formData.processName || 'SOP'"
|
||||
placeholder="请输入工序 SOP 操作说明,支持图文、表格等"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="SHOW_PROCESS_DATA_COLLECTION"
|
||||
id="form-section-data-collections"
|
||||
@@ -1043,6 +1055,7 @@
|
||||
import { packTypeLabel, packTypeTagType } from '@/utils/status-tag-registry'
|
||||
import { withFullscreenLoading, fullscreenLoadingText } from '@/utils/fullscreen-loading'
|
||||
import DialogFormBody from '@/components/business/dialog-form-body/index.vue'
|
||||
import TinyMceEditor from '@/components/TinyMceEditor/index.vue'
|
||||
import ProcessRouteTab from './ProcessRouteTab.vue'
|
||||
|
||||
defineOptions({ name: 'ProductionProcess' })
|
||||
@@ -1066,6 +1079,7 @@
|
||||
const formRef = ref()
|
||||
const editingId = ref<string | null>(null)
|
||||
const activeFormTab = ref('basic')
|
||||
const sopEditorKey = ref(0)
|
||||
|
||||
const formTabs = [
|
||||
{ key: 'basic', label: '基本信息' },
|
||||
@@ -1076,6 +1090,7 @@
|
||||
]
|
||||
: []),
|
||||
{ key: 'outputs', label: '产出物' },
|
||||
{ key: 'sop', label: 'SOP信息' },
|
||||
...(SHOW_PROCESS_DATA_COLLECTION
|
||||
? [{ key: 'data-collections', label: '数据采集' }]
|
||||
: [])
|
||||
@@ -1116,6 +1131,7 @@
|
||||
description: '',
|
||||
estimatedDuration: '',
|
||||
sopIds: [] as string[],
|
||||
sopContent: '',
|
||||
remark: ''
|
||||
})
|
||||
|
||||
@@ -1297,6 +1313,7 @@
|
||||
formData.description = ''
|
||||
formData.estimatedDuration = ''
|
||||
formData.sopIds = []
|
||||
formData.sopContent = ''
|
||||
formData.remark = ''
|
||||
reagentList.value = []
|
||||
consumableList.value = []
|
||||
@@ -1386,6 +1403,7 @@
|
||||
isEdit.value = false
|
||||
dialogTitle.value = '新增工艺'
|
||||
resetForm()
|
||||
sopEditorKey.value += 1
|
||||
dialogVisible.value = true
|
||||
dialogLoading.value = true
|
||||
nextTick(() => formRef.value?.clearValidate?.())
|
||||
@@ -1399,6 +1417,7 @@
|
||||
|
||||
async function handleEdit(row: any) {
|
||||
resetForm()
|
||||
sopEditorKey.value += 1
|
||||
isEdit.value = true
|
||||
dialogTitle.value = '编辑工艺'
|
||||
editingId.value = row.id
|
||||
@@ -1413,6 +1432,7 @@
|
||||
formData.description = detail.description || ''
|
||||
formData.estimatedDuration = detail.estimatedDuration || ''
|
||||
formData.sopIds = parseSopIds(detail.sopIds)
|
||||
formData.sopContent = detail.sopContent || ''
|
||||
formData.remark = detail.remark || ''
|
||||
reagentList.value = (detail.reagents || []).map((r: any) => ({
|
||||
...r,
|
||||
@@ -1455,6 +1475,7 @@
|
||||
description: formData.description?.trim() || undefined,
|
||||
estimatedDuration: formData.estimatedDuration?.trim() || undefined,
|
||||
sopIds: serializeSopIds(formData.sopIds),
|
||||
sopContent: formData.sopContent || undefined,
|
||||
remark: formData.remark?.trim() || undefined,
|
||||
reagents: reagentList.value.map((r) => ({
|
||||
reagentId: r.reagentId || r.id,
|
||||
@@ -1831,10 +1852,15 @@
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
.process-basic-info-grid {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.process-basic-extra-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 16px 20px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.form-col-span-2 {
|
||||
@@ -2003,7 +2029,7 @@
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.form-grid {
|
||||
.process-basic-extra-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -705,6 +705,18 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="route-config-step-section step-sop-content-section">
|
||||
<h3 class="route-config-step-section-title">SOP信息</h3>
|
||||
<TinyMceEditor
|
||||
:key="editor.selectedStep.value.id"
|
||||
v-model="editor.selectedStep.value.sopContent"
|
||||
:disabled="stepFormReadonly"
|
||||
show-print
|
||||
:print-title="editor.selectedStep.value.processName || editor.selectedStep.value.stepName || 'SOP'"
|
||||
placeholder="请输入或完善工序 SOP,执行过程中填写的内容将保存并回显"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<!-- 数据采集(SHOW_PROCESS_DATA_COLLECTION 控制展示) -->
|
||||
<section v-if="SHOW_PROCESS_DATA_COLLECTION" class="route-config-step-section">
|
||||
<h3 class="route-config-step-section-title">数据采集</h3>
|
||||
@@ -862,6 +874,7 @@
|
||||
} from '@/utils/process-material-ui'
|
||||
import { formatPlannedStartDate } from '@/utils/production-labels'
|
||||
import StepDetailActionBar from './StepDetailActionBar.vue'
|
||||
import TinyMceEditor from '@/components/TinyMceEditor/index.vue'
|
||||
import { useTaskStepEditor } from './useTaskStepEditor'
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -966,6 +979,10 @@
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.step-sop-content-section {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.step-assignee-readonly-tag {
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
@@ -126,6 +126,7 @@ export function serializeStep(step: Record<string, any>): string {
|
||||
description: step.description,
|
||||
estimatedDuration: step.estimatedDuration,
|
||||
sopIds: step.sopIds,
|
||||
sopContent: step.sopContent,
|
||||
assigneeUserIds: [...(step.assigneeUserIds || [])].sort(),
|
||||
plannedStartTime: step.plannedStartTime || '',
|
||||
reagents: normalizeReagents(step.reagents),
|
||||
|
||||
@@ -94,6 +94,7 @@ function normalizeStepSubTables(step: Record<string, any>) {
|
||||
assigneeUserIds,
|
||||
assignees: step.assignees || [],
|
||||
plannedStartTime,
|
||||
sopContent: step.sopContent || '',
|
||||
_snapshotAssigneeUserIds: [...assigneeUserIds],
|
||||
_snapshotPlannedStartTime: plannedStartTime || '',
|
||||
reagents: (step.reagents || []).map((r: Record<string, any>) => ({
|
||||
@@ -1302,7 +1303,8 @@ export function useTaskStepEditor() {
|
||||
category_id: selectedStep.value.categoryId,
|
||||
description: selectedStep.value.description,
|
||||
estimated_duration: selectedStep.value.estimatedDuration,
|
||||
sop_ids: selectedStep.value.sopIds
|
||||
sop_ids: selectedStep.value.sopIds,
|
||||
sop_content: selectedStep.value.sopContent
|
||||
},
|
||||
operateScope.value
|
||||
)
|
||||
@@ -1992,7 +1994,8 @@ export function useTaskStepEditor() {
|
||||
category_id: step.categoryId,
|
||||
description: step.description,
|
||||
estimated_duration: step.estimatedDuration,
|
||||
sop_ids: step.sopIds
|
||||
sop_ids: step.sopIds,
|
||||
sop_content: step.sopContent
|
||||
},
|
||||
operateScope.value
|
||||
)
|
||||
|
||||
Reference in new issue
Block a user