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

+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;
/* 避免内部   把基线抬到方框中部;略上移与汉字底边视觉对齐 */
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
}