提交
This commit is contained in:
1 parent
d2290eb92c
commit
a2d4d3b0d5
320 files changed
+125011
-26951
No files matched your search
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user