This commit is contained in:
zlwu committed 2026-08-25 16:47:43 +08:00
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>
+574
View File
@@ -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;
/* 避免内部 &nbsp; 把基线抬到方框中部;略上移与汉字底边视觉对齐 */
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 = '&nbsp;'
}
// 用属性兜底勾选态,便于序列化与打印
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
)