feat(contract): 接入移动端合同打印与小程序文档交付以支持导出分享。
This commit is contained in:
1 parent
0cd120ef5e
commit
13630045df
16 files changed
+3666
-4
No files matched your search
@@ -1,5 +1,6 @@
|
||||
<template>
|
||||
<component :is="props.embedded ? 'div' : MpWebviewShell" :class="{ 'mp-contract-embedded': props.embedded }" @ready="onShellReady">
|
||||
<MobileContractPrint v-if="printActive" :contract-id="printContractId" @close="printActive = false" />
|
||||
<div
|
||||
class="mp-h5-detail-page mp-contract-detail-page mp-contract-detail-touch"
|
||||
:style="{
|
||||
@@ -858,6 +859,10 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const MobileContractPrint = defineAsyncComponent(() => import('./print/MobileContractPrint.vue'))
|
||||
const printActive = ref(false)
|
||||
const printContractId = ref('')
|
||||
|
||||
import { mpContractRelatedLocation } from '@/utils/mp-webview'
|
||||
import ContractPickerSheet from './ContractPickerSheet.vue'
|
||||
import { buildPathLabelMapFromTreeRoots } from '@/utils/tree-path'
|
||||
@@ -1291,13 +1296,14 @@
|
||||
{ key: 'reject', label: '驳回', danger: true }
|
||||
)
|
||||
}
|
||||
actions.push({ key: 'upload', label: '上传扫描件' })
|
||||
actions.push({ key: 'print', label: '合同打印' }, { key: 'upload', label: '上传扫描件' })
|
||||
return actions
|
||||
}
|
||||
|
||||
const status = c.status || ''
|
||||
if (status === 'invalid') {
|
||||
return [
|
||||
{ key: 'print', label: '合同打印' },
|
||||
{ key: 'restore', label: '作废恢复' },
|
||||
{ key: 'copy', label: '复制' },
|
||||
{ key: 'upload', label: '上传扫描件' }
|
||||
@@ -1320,7 +1326,7 @@
|
||||
if (canInvalid.value) {
|
||||
actions.push({ key: 'void', label: '作废', danger: true })
|
||||
}
|
||||
actions.push({ key: 'copy', label: '复制' }, { key: 'upload', label: '上传扫描件' })
|
||||
actions.push({ key: 'print', label: '合同打印' }, { key: 'copy', label: '复制' }, { key: 'upload', label: '上传扫描件' })
|
||||
return actions
|
||||
})
|
||||
|
||||
@@ -1487,6 +1493,7 @@
|
||||
)
|
||||
}
|
||||
function closeRecordActions() {
|
||||
printActive.value = false
|
||||
operationEpoch++
|
||||
acting.value = false
|
||||
moreOpen.value = false
|
||||
@@ -1663,8 +1670,14 @@
|
||||
}
|
||||
|
||||
async function runAction(key: string) {
|
||||
if (!captureOperation(key)) return
|
||||
const operation = captureOperation(key)
|
||||
if (!operation) return
|
||||
moreOpen.value = false
|
||||
if (key === 'print') {
|
||||
printContractId.value = operation.id
|
||||
printActive.value = true
|
||||
return
|
||||
}
|
||||
if (key === 'submit') {
|
||||
await openSubmitSheet()
|
||||
return
|
||||
|
||||
@@ -0,0 +1,782 @@
|
||||
<template>
|
||||
<div class="tinymce-editor-container">
|
||||
<div v-if="!editorReady" class="mobile-editor-status" :role="initError ? 'alert' : 'status'">
|
||||
<span>{{ initError || '合同编辑器正在加载…' }}</span>
|
||||
<el-button v-if="initError" @click="retryEditor"
|
||||
><el-icon><Refresh /></el-icon
|
||||
>{{ reloadPageRequired ? '重新加载页面' : '重新加载编辑器' }}</el-button
|
||||
>
|
||||
</div>
|
||||
<Editor
|
||||
v-if="runtimeReady && !initError"
|
||||
:key="editorAttempt"
|
||||
:id="editorId"
|
||||
v-model="contentValue"
|
||||
:init="editorInit"
|
||||
:disabled="disabled"
|
||||
tinymce-script-src="/tinymce/tinymce.min.js"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Editor from '@tinymce/tinymce-vue'
|
||||
import { Refresh } from '@element-plus/icons-vue'
|
||||
import { setupSealPointerDrag } from './sealPointerDrag'
|
||||
import { fetchUploadAttachment } from '@/api/admin'
|
||||
import { resolveCrmAttachmentUrl } from '@/utils/crm-attachment'
|
||||
import { buildAttachmentUploadFormData, CRM_UPLOAD_BIZ_TYPE } from '@/utils/crm-upload'
|
||||
import { setupTableEdgeResize } from '@/utils/tinymce-table-edge-resize'
|
||||
|
||||
/** 须高于 ElConfigProvider 初始 z-index(3000) 及多层 el-dialog 叠加 */
|
||||
onMounted(() => document.body.classList.add('mp-contract-editor-active'))
|
||||
onBeforeUnmount(() => document.body.classList.remove('mp-contract-editor-active'))
|
||||
const TINYMCE_Z_INDEX_FLOOR = 10000
|
||||
|
||||
/** 读取当前页面 Element Plus 弹层最高 z-index,保证 TinyMCE 浮层在其上 */
|
||||
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)
|
||||
})
|
||||
}
|
||||
|
||||
interface Props {
|
||||
modelValue?: string
|
||||
disabled?: boolean
|
||||
height?: number
|
||||
toolbar?: boolean
|
||||
contractNo?: string // 合同编号,用于提取供方编码
|
||||
supplierCode?: string // 供应商编码,直接传入(优先级高于从contractNo提取)
|
||||
/** TinyMCE 编辑区 body 内边距,默认 20px;入库单等紧凑预览可传 0 */
|
||||
contentBodyPadding?: string
|
||||
/** TinyMCE 表格单元格内边距,默认 10px;入库单等可传 4px */
|
||||
tableCellPadding?: string
|
||||
/** 追加到 TinyMCE content_style(如入库单打印专用 CSS) */
|
||||
contentStyleExtra?: string
|
||||
/** 编辑器 setup 阶段追加回调(如送(提)货单多选项交互) */
|
||||
editorSetupExtra?: (editor: any) => void
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
modelValue: '',
|
||||
disabled: false,
|
||||
height: 600,
|
||||
toolbar: true,
|
||||
contractNo: '',
|
||||
supplierCode: '',
|
||||
contentBodyPadding: '20px',
|
||||
tableCellPadding: '10px',
|
||||
contentStyleExtra: '',
|
||||
editorSetupExtra: undefined
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'ready-change'])
|
||||
const editorReady = ref(false)
|
||||
const runtimeReady = ref(false)
|
||||
let pendingScript: HTMLScriptElement | null = null
|
||||
const initError = ref('')
|
||||
const reloadPageRequired = ref(false)
|
||||
const editorAttempt = ref(0)
|
||||
let initTimer: ReturnType<typeof setTimeout> | undefined
|
||||
let editorDisposed = false
|
||||
const requestedAddons: Array<{ manager: string; name: string }> = []
|
||||
const failEditor = (message: string) => {
|
||||
if (editorDisposed || editorReady.value) return
|
||||
clearTimeout(initTimer)
|
||||
initError.value = message
|
||||
emit('ready-change', false)
|
||||
}
|
||||
const startInitTimer = () => {
|
||||
clearTimeout(initTimer)
|
||||
initTimer = setTimeout(() => {
|
||||
reloadPageRequired.value = true
|
||||
failEditor('编辑器加载超时,请重新加载页面。将重新打开当前合同,已保存内容不会丢失。')
|
||||
}, 20000)
|
||||
}
|
||||
const loadEditorRuntime = () => {
|
||||
if ((window as any).tinymce) {
|
||||
runtimeReady.value = true
|
||||
return
|
||||
}
|
||||
const script = document.createElement('script')
|
||||
pendingScript = script
|
||||
script.src = '/tinymce/tinymce.min.js'
|
||||
script.onload = () => {
|
||||
if (editorDisposed || pendingScript !== script) return
|
||||
pendingScript = null
|
||||
script.onload = script.onerror = null
|
||||
if ((window as any).tinymce) runtimeReady.value = true
|
||||
else failEditor('编辑器脚本未能启动,请重新加载编辑器。合同内容已保留。')
|
||||
}
|
||||
script.onerror = () => {
|
||||
if (editorDisposed || pendingScript !== script) return
|
||||
pendingScript = null
|
||||
script.remove()
|
||||
failEditor('编辑器脚本加载失败,请检查网络后重试。合同内容已保留。')
|
||||
}
|
||||
document.head.appendChild(script)
|
||||
}
|
||||
const clearPendingScript = () => {
|
||||
if (!pendingScript) return
|
||||
pendingScript.onload = pendingScript.onerror = null
|
||||
pendingScript.remove()
|
||||
pendingScript = null
|
||||
}
|
||||
const retryEditor = () => {
|
||||
if (reloadPageRequired.value) {
|
||||
// 请求持续挂起时,TinyMCE 的共享脚本队列仍在等待;完整刷新释放旧队列。
|
||||
const url = new URL(window.location.href)
|
||||
const [path, query = ''] = url.hash.slice(1).split('?')
|
||||
const params = new URLSearchParams(query)
|
||||
params.set('action', 'print')
|
||||
url.hash = `${path}?${params.toString()}`
|
||||
window.history.replaceState(window.history.state, '', url.href)
|
||||
window.location.reload()
|
||||
return
|
||||
}
|
||||
clearPendingScript()
|
||||
// 超时和失败都会留下地址缓存;仅清理本编辑器请求且尚未注册的项。
|
||||
const tiny = (window as any).tinymce
|
||||
for (const { manager, name } of requestedAddons.splice(0)) {
|
||||
const registry = tiny?.[manager]
|
||||
if (name && registry && !registry.get(name)) registry.remove(name)
|
||||
}
|
||||
tinymceEditor = null
|
||||
editorReady.value = false
|
||||
initError.value = ''
|
||||
editorAttempt.value++
|
||||
emit('ready-change', false)
|
||||
startInitTimer()
|
||||
loadEditorRuntime()
|
||||
}
|
||||
onMounted(() => {
|
||||
startInitTimer()
|
||||
loadEditorRuntime()
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
editorDisposed = true
|
||||
clearPendingScript()
|
||||
clearTimeout(initTimer)
|
||||
tinymceEditor = null
|
||||
})
|
||||
|
||||
// 生成唯一 ID
|
||||
const editorId = `tinymce-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
|
||||
|
||||
// 内容
|
||||
const contentValue = ref(props.modelValue)
|
||||
|
||||
// 监听外部值变化
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(newVal) => {
|
||||
if (newVal !== contentValue.value) {
|
||||
contentValue.value = newVal
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// 监听内容变化
|
||||
watch(contentValue, (newVal) => {
|
||||
emit('update:modelValue', newVal)
|
||||
})
|
||||
|
||||
// 从合同编号中提取供方编码
|
||||
const getSupplierCode = (contractNo?: string): string => {
|
||||
if (!contractNo) return 'SX' // 默认值
|
||||
// 合同编号格式:{供方编码}/X-JT + yyyyMMdd + 3位流水号
|
||||
// 例如:SX/X-JT20250926002
|
||||
const match = contractNo.match(/^([A-Z]+)\/X-JT/)
|
||||
return match && match[1] ? match[1] : 'SX'
|
||||
}
|
||||
|
||||
// 获取供应商编码(优先使用直接传入的supplierCode,否则从contractNo提取)
|
||||
const getCurrentSupplierCode = computed(() => {
|
||||
if (props.supplierCode) {
|
||||
return props.supplierCode
|
||||
}
|
||||
return getSupplierCode(props.contractNo)
|
||||
})
|
||||
|
||||
// 判断是否为SX供方编码
|
||||
const isSXSupplier = computed(() => {
|
||||
return getCurrentSupplierCode.value === 'SX'
|
||||
})
|
||||
|
||||
// TinyMCE 配置
|
||||
let tinymceEditor: any = null
|
||||
|
||||
const editorInit = computed(() => {
|
||||
const baseZIndex = resolveTinyMceBaseZIndex()
|
||||
const backgroundStyle = isSXSupplier.value
|
||||
? `
|
||||
/* 编辑区水印贴在视口右下角,避免盖住产品表单价。 */
|
||||
html { background: #fff; }
|
||||
body { background: transparent !important; }
|
||||
body::before {
|
||||
content: '';
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background-image: url('/watermask.jpg');
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 16px bottom 16px;
|
||||
background-size: 140px auto;
|
||||
opacity: 0.18;
|
||||
mix-blend-mode: multiply;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
`
|
||||
: ''
|
||||
|
||||
return {
|
||||
// 基础路径配置
|
||||
skin_url: '/tinymce/skins/ui/oxide',
|
||||
cache_suffix: editorAttempt.value ? `?mpPrintRetry=${editorAttempt.value}` : '',
|
||||
...(editorAttempt.value
|
||||
? {
|
||||
model_url: `/tinymce/models/dom/model.min.js?mpPrintRetry=${editorAttempt.value}`,
|
||||
theme_url: `/tinymce/themes/silver/theme.min.js?mpPrintRetry=${editorAttempt.value}`
|
||||
}
|
||||
: {}),
|
||||
|
||||
// 语言配置
|
||||
language: 'zh_CN',
|
||||
language_url: '/tinymce/langs/zh_CN.js',
|
||||
|
||||
// 基础设置
|
||||
// 移动 WebView 使用同步 iframe 初始化,避免 srcdoc 的 load 未完成时停在空白编辑区。
|
||||
init_content_sync: true,
|
||||
height: props.height,
|
||||
menubar: false,
|
||||
mobile: { toolbar_mode: 'wrap' },
|
||||
statusbar: false,
|
||||
readonly: props.disabled,
|
||||
|
||||
// 插件(如果有工具栏才加载)
|
||||
plugins: props.toolbar
|
||||
? [
|
||||
'advlist',
|
||||
'autolink',
|
||||
'lists',
|
||||
'link',
|
||||
'image',
|
||||
'charmap',
|
||||
'anchor',
|
||||
'searchreplace',
|
||||
'visualblocks',
|
||||
'code',
|
||||
'fullscreen',
|
||||
'insertdatetime',
|
||||
'media',
|
||||
'table',
|
||||
'help',
|
||||
'wordcount'
|
||||
]
|
||||
: [],
|
||||
|
||||
// 工具栏:突出表格排版(合并/拆分、单元格属性、对齐、换行)+ 盖章
|
||||
toolbar: props.toolbar
|
||||
? 'undo redo | ' +
|
||||
'bold italic underline | ' +
|
||||
'alignleft aligncenter alignright | ' +
|
||||
'table tableprops tablecellprops tablemergecells tablesplitcells | ' +
|
||||
'tablecellhalign tablecellvalign tablecellwrap | ' +
|
||||
'bullist numlist | ' +
|
||||
'link image | ' +
|
||||
'sealMode cancelSeal | ' +
|
||||
'forecolor backcolor | ' +
|
||||
'removeformat'
|
||||
: false,
|
||||
|
||||
// 内容样式
|
||||
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: #333;
|
||||
padding: ${props.contentBodyPadding};
|
||||
position: relative;
|
||||
min-height: 100vh;
|
||||
min-width: 754px;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
}
|
||||
img[data-seal] { touch-action: none; }
|
||||
${backgroundStyle}
|
||||
/* 表格:固定布局;不抢占内联宽高/对齐/边框 */
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
margin-bottom: 20px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
table td, table th {
|
||||
border: 1px solid #ddd;
|
||||
padding: ${props.tableCellPadding};
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
white-space: normal;
|
||||
vertical-align: middle;
|
||||
cursor: inherit;
|
||||
/* 与 getBoundingClientRect 量到的边框盒宽度一致,避免快照写回时每列被放大 */
|
||||
box-sizing: border-box;
|
||||
}
|
||||
table th {
|
||||
background-color: #f5f5f5;
|
||||
font-weight: bold;
|
||||
}
|
||||
/* 列宽/行高拖拽已关闭,避免调整条改写表格列宽 */
|
||||
.ephox-snooker-resizer-bar {
|
||||
display: none !important;
|
||||
}
|
||||
h1 {
|
||||
text-align: center;
|
||||
font-size: 28px;
|
||||
margin-bottom: 10px;
|
||||
font-weight: bold;
|
||||
}
|
||||
h2 {
|
||||
text-align: center;
|
||||
font-size: 24px;
|
||||
margin-bottom: 30px;
|
||||
font-weight: bold;
|
||||
}
|
||||
h3 {
|
||||
font-size: 18px;
|
||||
margin-top: 30px;
|
||||
margin-bottom: 15px;
|
||||
border-bottom: 2px solid #333;
|
||||
padding-bottom: 5px;
|
||||
font-weight: bold;
|
||||
}
|
||||
p {
|
||||
line-height: 1.8;
|
||||
margin-bottom: 15px;
|
||||
position: relative;
|
||||
}
|
||||
div {
|
||||
line-height: 1.6;
|
||||
position: relative;
|
||||
}
|
||||
strong {
|
||||
font-weight: bold;
|
||||
}
|
||||
/* 盖章图片样式 */
|
||||
img[data-seal] {
|
||||
position: absolute !important;
|
||||
z-index: 999 !important;
|
||||
}
|
||||
${props.contentStyleExtra || ''}
|
||||
`,
|
||||
|
||||
// 保留所有 HTML
|
||||
extended_valid_elements: '*[*]',
|
||||
valid_elements: '*[*]',
|
||||
|
||||
// 表格配置:固定像素列宽,不显示列/行拖拽条
|
||||
table_toolbar:
|
||||
'tableprops tablecellprops tabledelete | tablemergecells tablesplitcells | tableinsertrowbefore tableinsertrowafter tabledeleterow | tableinsertcolbefore tableinsertcolafter tabledeletecol | tablecellhalign tablecellvalign tablecellwrap',
|
||||
table_appearance_options: true,
|
||||
table_grid: true,
|
||||
table_resize_bars: false,
|
||||
table_sizing_mode: 'fixed',
|
||||
table_use_colgroups: true,
|
||||
table_default_attributes: {
|
||||
border: '1'
|
||||
},
|
||||
table_default_styles: {
|
||||
'border-collapse': 'collapse',
|
||||
width: '100%'
|
||||
},
|
||||
|
||||
// 其他配置
|
||||
branding: false,
|
||||
promotion: false,
|
||||
|
||||
// 设置弹窗的 z-index,确保在 Element Plus Dialog 之上(须高于 App.vue ElConfigProvider :z-index="3000")
|
||||
base_z_index: baseZIndex,
|
||||
|
||||
// 图片上传配置(复用门户统一附件接口,与 CRM 其它上传一致)
|
||||
images_upload_handler: (blobInfo: any, progress: any) => {
|
||||
return new Promise(async (resolve, reject) => {
|
||||
try {
|
||||
progress(10)
|
||||
const file = new File([blobInfo.blob()], blobInfo.filename(), {
|
||||
type: blobInfo.blob().type || 'image/jpeg'
|
||||
})
|
||||
const formData = buildAttachmentUploadFormData(file, {
|
||||
bizType: CRM_UPLOAD_BIZ_TYPE.QUOTE_ATTACHMENT
|
||||
})
|
||||
progress(40)
|
||||
const res = await fetchUploadAttachment(formData)
|
||||
const fileUrl = res?.fileUrl
|
||||
if (!fileUrl) {
|
||||
reject('上传失败')
|
||||
return
|
||||
}
|
||||
progress(100)
|
||||
const imageUrl = resolveCrmAttachmentUrl(fileUrl)
|
||||
resolve(imageUrl)
|
||||
console.log('图片上传成功:', imageUrl)
|
||||
} catch (error: any) {
|
||||
const errorMsg = error?.message || '上传失败'
|
||||
reject(errorMsg)
|
||||
console.error('图片上传失败:', errorMsg)
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
// 允许粘贴图片
|
||||
paste_data_images: true,
|
||||
automatic_uploads: true,
|
||||
|
||||
// 图片文件类型
|
||||
file_picker_types: 'image',
|
||||
|
||||
// 图片上传限制
|
||||
images_file_types: 'jpg,jpeg,png,gif,bmp,webp',
|
||||
|
||||
// 图片高级编辑功能
|
||||
image_advtab: true,
|
||||
image_caption: true,
|
||||
image_title: true,
|
||||
|
||||
// 图片仍可拖拽缩放;表格只在第一行右边界、第一列下边界拖拽
|
||||
object_resizing: 'img,figure.image,div,video,iframe',
|
||||
|
||||
// 允许拖拽图片
|
||||
draggable_modal: false,
|
||||
|
||||
// 初始化回调
|
||||
setup: (editor: any) => {
|
||||
tinymceEditor = editor
|
||||
requestedAddons.push({ manager: 'ModelManager', name: editor.options.get('model') })
|
||||
requestedAddons.push({ manager: 'ThemeManager', name: editor.options.get('theme') })
|
||||
for (const name of editor.options.get('plugins')) {
|
||||
requestedAddons.push({ manager: 'PluginManager', name })
|
||||
}
|
||||
editor.on('init', () => {
|
||||
if (editorDisposed || initError.value || editor !== tinymceEditor || !editor.serializer)
|
||||
return
|
||||
clearTimeout(initTimer)
|
||||
editorReady.value = true
|
||||
initError.value = ''
|
||||
emit('ready-change', true)
|
||||
})
|
||||
editor.on('SkinLoadError ThemeLoadError ModelLoadError PluginLoadError', () => {
|
||||
if (editor === tinymceEditor)
|
||||
failEditor('编辑器资源加载失败,请检查网络后重试。合同内容已保留。')
|
||||
})
|
||||
props.editorSetupExtra?.(editor)
|
||||
setupTableEdgeResize(editor)
|
||||
|
||||
const applyCellStyle = (style: string, value: string) => {
|
||||
if (editor.queryCommandSupported('mceTableApplyCellStyle')) {
|
||||
editor.execCommand('mceTableApplyCellStyle', false, { [style]: value })
|
||||
}
|
||||
}
|
||||
|
||||
// 单元格水平对齐(table 插件自带垂直对齐 tablecellvalign,水平需自建)
|
||||
editor.ui.registry.addMenuButton('tablecellhalign', {
|
||||
icon: 'align-center',
|
||||
tooltip: '单元格水平对齐',
|
||||
fetch: (callback: (items: any[]) => void) => {
|
||||
callback([
|
||||
{
|
||||
type: 'menuitem',
|
||||
text: '左对齐',
|
||||
icon: 'align-left',
|
||||
onAction: () => applyCellStyle('text-align', 'left')
|
||||
},
|
||||
{
|
||||
type: 'menuitem',
|
||||
text: '居中',
|
||||
icon: 'align-center',
|
||||
onAction: () => applyCellStyle('text-align', 'center')
|
||||
},
|
||||
{
|
||||
type: 'menuitem',
|
||||
text: '右对齐',
|
||||
icon: 'align-right',
|
||||
onAction: () => applyCellStyle('text-align', 'right')
|
||||
}
|
||||
])
|
||||
}
|
||||
})
|
||||
|
||||
// 单元格文字换行
|
||||
editor.ui.registry.addMenuButton('tablecellwrap', {
|
||||
icon: 'line',
|
||||
tooltip: '单元格文字换行',
|
||||
fetch: (callback: (items: any[]) => void) => {
|
||||
callback([
|
||||
{
|
||||
type: 'menuitem',
|
||||
text: '自动换行',
|
||||
onAction: () => {
|
||||
applyCellStyle('white-space', 'normal')
|
||||
applyCellStyle('word-wrap', 'break-word')
|
||||
applyCellStyle('overflow-wrap', 'break-word')
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'menuitem',
|
||||
text: '不换行',
|
||||
onAction: () => applyCellStyle('white-space', 'nowrap')
|
||||
}
|
||||
])
|
||||
}
|
||||
})
|
||||
|
||||
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)
|
||||
|
||||
// 编辑器初始化完成
|
||||
// 注意:水印已在 content_style 的 body::before 伪元素中设置,无需重复添加
|
||||
editor.on('init', () => {
|
||||
console.log('TinyMCE编辑器初始化完成')
|
||||
})
|
||||
|
||||
// 注册"盖章模式"按钮 - 可拖动的浮动图片
|
||||
editor.ui.registry.addButton('sealMode', {
|
||||
text: '盖章模式',
|
||||
tooltip: '将选中的图片设置为可拖动的盖章(浮于文字上方)',
|
||||
onAction: () => {
|
||||
const img = editor.selection.getNode()
|
||||
if (img && img.nodeName === 'IMG') {
|
||||
console.log('选中的图片:', img)
|
||||
console.log('图片 src:', img.src)
|
||||
console.log('图片宽度:', img.width)
|
||||
|
||||
const imageRect = img.getBoundingClientRect()
|
||||
const positioningParent = img.offsetParent || editor.getBody()
|
||||
const parentRect = positioningParent.getBoundingClientRect()
|
||||
const imgWidth = img.width || 120
|
||||
|
||||
// 直接修改图片样式,不删除重建
|
||||
editor.dom.setStyles(img, {
|
||||
position: 'absolute',
|
||||
left: `${imageRect.left - parentRect.left + positioningParent.scrollLeft - positioningParent.clientLeft}px`,
|
||||
top: `${imageRect.top - parentRect.top + positioningParent.scrollTop - positioningParent.clientTop}px`,
|
||||
right: 'auto',
|
||||
bottom: 'auto',
|
||||
transform: 'none',
|
||||
'touch-action': 'none',
|
||||
'z-index': '9999',
|
||||
opacity: '0.6',
|
||||
width: imgWidth + 'px',
|
||||
cursor: 'move'
|
||||
})
|
||||
editor.dom.setAttrib(img, 'data-seal', 'draggable')
|
||||
editor.dom.addClass(img, 'seal-image-draggable')
|
||||
|
||||
editor.fire('change')
|
||||
|
||||
console.log('盖章设置完成,图片样式:', img.style.cssText)
|
||||
alert('盖章已设置!现在可以拖动图片到任意位置')
|
||||
} else {
|
||||
alert('请先选中一张图片')
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// 注册"取消盖章"按钮
|
||||
editor.ui.registry.addButton('cancelSeal', {
|
||||
text: '取消盖章',
|
||||
tooltip: '将图片恢复为正常模式',
|
||||
onAction: () => {
|
||||
const selectedImage = editor.selection.getNode()
|
||||
if (selectedImage && selectedImage.nodeName === 'IMG') {
|
||||
// 移除盖章样式
|
||||
editor.dom.setStyles(selectedImage, {
|
||||
position: '',
|
||||
left: '',
|
||||
right: '',
|
||||
top: '',
|
||||
bottom: '',
|
||||
transform: '',
|
||||
'z-index': '',
|
||||
opacity: '',
|
||||
'pointer-events': '',
|
||||
'touch-action': '',
|
||||
cursor: '',
|
||||
'max-width': ''
|
||||
})
|
||||
editor.dom.setAttrib(selectedImage, 'data-seal', null)
|
||||
editor.dom.setAttrib(selectedImage, 'draggable', null)
|
||||
|
||||
// 恢复父容器样式
|
||||
const parent = selectedImage.parentElement
|
||||
if (parent) {
|
||||
editor.dom.setStyle(parent, 'position', '')
|
||||
}
|
||||
|
||||
editor.fire('change')
|
||||
} else {
|
||||
alert('请先选中一张图片')
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
editor.on('init', () => {
|
||||
console.log('TinyMCE 编辑器初始化完成')
|
||||
console.log('内容长度:', editor.getContent().length)
|
||||
|
||||
// 只读模式隐藏工具栏
|
||||
if (props.disabled) {
|
||||
const container = editor.getContainer()
|
||||
const toolbar = container.querySelector('.tox-toolbar')
|
||||
if (toolbar) {
|
||||
toolbar.style.display = 'none'
|
||||
}
|
||||
}
|
||||
|
||||
// 根据供方编码控制logo显示的函数
|
||||
const hideLogoIfNeeded = () => {
|
||||
const editorBody = editor.getBody()
|
||||
if (editor.removed || !editorBody) return
|
||||
const supplierCode = getCurrentSupplierCode.value
|
||||
const isSX = supplierCode === 'SX'
|
||||
|
||||
if (!isSX) {
|
||||
// 如果不是SX供方编码,隐藏logo
|
||||
const logoImages = editorBody.querySelectorAll('img')
|
||||
logoImages.forEach((img: any) => {
|
||||
const src = img.src || img.getAttribute('src') || ''
|
||||
// 检查是否是logo图片(路径包含logo.gif或logo)
|
||||
if (
|
||||
src.includes('logo.gif') ||
|
||||
src.includes('/logo') ||
|
||||
(src.includes('logo') && !src.includes('watermask'))
|
||||
) {
|
||||
// 隐藏logo及其父容器(如果有)
|
||||
img.style.display = 'none'
|
||||
const parent = img.parentElement
|
||||
if (parent && parent.tagName === 'DIV' && parent.children.length === 1) {
|
||||
parent.style.display = 'none'
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化时隐藏logo
|
||||
setTimeout(() => {
|
||||
hideLogoIfNeeded()
|
||||
}, 500)
|
||||
|
||||
// 监听内容变化,重新检查logo
|
||||
editor.on('SetContent', () => {
|
||||
setTimeout(() => {
|
||||
hideLogoIfNeeded()
|
||||
}, 100)
|
||||
})
|
||||
|
||||
// 监听节点变化,重新检查logo
|
||||
editor.on('NodeChange', () => {
|
||||
setTimeout(() => {
|
||||
hideLogoIfNeeded()
|
||||
}, 100)
|
||||
})
|
||||
|
||||
setupSealPointerDrag(editor)
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
/** 导出、保存前读取编辑器里正在显示的 HTML(含拖过的列宽、行高)。 */
|
||||
const getHtml = (): string => {
|
||||
if (
|
||||
!editorReady.value ||
|
||||
!tinymceEditor?.initialized ||
|
||||
!tinymceEditor?.serializer ||
|
||||
tinymceEditor.removed
|
||||
) {
|
||||
throw new Error('合同编辑器尚未就绪,请等待加载完成或重新加载编辑器')
|
||||
}
|
||||
return tinymceEditor.getContent()
|
||||
}
|
||||
|
||||
defineExpose({ getHtml })
|
||||
|
||||
onMounted(() => {
|
||||
console.log('TinyMCE 组件挂载,内容:', props.modelValue?.substring(0, 100))
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.mobile-editor-status {
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
color: #6b7b74;
|
||||
}
|
||||
.tinymce-editor-container {
|
||||
:deep(.tox-tinymce) {
|
||||
border: 1px solid #dcdfe6;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
:deep(.tox-editor-header) {
|
||||
border-bottom: 1px solid #dcdfe6;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="scss">
|
||||
body.mp-contract-editor-active .tox-tinymce-aux,
|
||||
body.mp-contract-editor-active .tox-dialog-wrap {
|
||||
z-index: 10000 !important;
|
||||
}
|
||||
body.mp-contract-editor-active .tox-dialog {
|
||||
z-index: 10001 !important;
|
||||
}
|
||||
body.mp-contract-editor-active .tox-dialog__backdrop {
|
||||
z-index: 9999 !important;
|
||||
}
|
||||
body.mp-contract-editor-active .tox-menu,
|
||||
body.mp-contract-editor-active .tox-pop {
|
||||
z-index: 10002 !important;
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,90 @@
|
||||
import FileSaver from 'file-saver'
|
||||
import { fetchUploadAttachment, fetchAttachmentDownloadUrl } from '@/api/admin'
|
||||
import { isInMiniProgramWebView } from '@/utils/mp-webview'
|
||||
import { buildAttachmentUploadFormData, CRM_UPLOAD_BIZ_TYPE } from '@/utils/crm-upload'
|
||||
|
||||
type MiniDocumentApi = {
|
||||
getEnv?: (callback: (env: { miniprogram?: boolean }) => void) => void
|
||||
navigateTo: (options: { url: string; success: () => void; fail: () => void }) => void
|
||||
}
|
||||
|
||||
/** 微信桥接就绪前,同步环境标记可能不存在;此时不能退回浏览器 Blob 下载。 */
|
||||
async function resolveMiniDocumentApi(): Promise<MiniDocumentApi | null> {
|
||||
if (!isInMiniProgramWebView() && !/MicroMessenger/i.test(navigator.userAgent)) return null
|
||||
return new Promise((resolve, reject) => {
|
||||
const script = document.querySelector('script[data-mp-jweixin]')
|
||||
let checking = false
|
||||
const finish = (mini?: MiniDocumentApi, message?: string) => {
|
||||
clearTimeout(timer)
|
||||
script?.removeEventListener('load', check)
|
||||
script?.removeEventListener('error', failed)
|
||||
document.removeEventListener('WeixinJSBridgeReady', check)
|
||||
if (mini) resolve(mini)
|
||||
else reject(new Error(message || '微信文档接口未就绪,请返回合同后重新打开打印窗口'))
|
||||
}
|
||||
const failed = () => finish()
|
||||
const check = () => {
|
||||
const mini = (window as unknown as { wx?: { miniProgram?: MiniDocumentApi } }).wx?.miniProgram
|
||||
if (!mini?.navigateTo) return
|
||||
if (isInMiniProgramWebView()) return finish(mini)
|
||||
if (checking || !mini.getEnv) return
|
||||
checking = true
|
||||
try {
|
||||
mini.getEnv((env) => {
|
||||
if (env.miniprogram) finish(mini)
|
||||
else finish(undefined, '请从小程序的合同管理进入后生成文档')
|
||||
})
|
||||
} catch {
|
||||
finish()
|
||||
}
|
||||
}
|
||||
const timer = setTimeout(failed, 8000)
|
||||
script?.addEventListener('load', check)
|
||||
script?.addEventListener('error', failed)
|
||||
document.addEventListener('WeixinJSBridgeReady', check)
|
||||
check()
|
||||
})
|
||||
}
|
||||
|
||||
/** 微信文件通过原生页打开,浏览器仍使用原下载方式。 */
|
||||
export async function deliverContractFile(blob: Blob, fileName: string, isActive = () => true) {
|
||||
if (!isActive()) return
|
||||
const mini = await resolveMiniDocumentApi()
|
||||
if (!isActive()) return
|
||||
if (!mini) {
|
||||
FileSaver.saveAs(blob, fileName)
|
||||
return 'browser-download' as const
|
||||
}
|
||||
const type = fileName.toLowerCase().endsWith('.pdf') ? 'pdf' : 'docx'
|
||||
const data = buildAttachmentUploadFormData(new File([blob], fileName, { type: blob.type }), {
|
||||
bizType: CRM_UPLOAD_BIZ_TYPE.CONTRACT_ATTACHMENT
|
||||
})
|
||||
const response: any = await fetchUploadAttachment(data)
|
||||
if (!isActive()) return
|
||||
const uploaded = response?.data ?? response
|
||||
if (!uploaded?.fileUrl) throw new Error('生成文件上传失败,请重试')
|
||||
const result: any = await fetchAttachmentDownloadUrl({ fileUrl: uploaded.fileUrl, fileName })
|
||||
if (!isActive()) return
|
||||
const url = (result?.data ?? result)?.downloadUrl
|
||||
if (!url) throw new Error('无法取得文件下载地址')
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const timer = setTimeout(() => reject(new Error('文档页打开超时,请返回合同后重试')), 10000)
|
||||
try {
|
||||
mini.navigateTo({
|
||||
url: `/pages/common/contract-document?url=${encodeURIComponent(url)}&type=${type}&name=${encodeURIComponent(fileName)}`,
|
||||
success: () => {
|
||||
clearTimeout(timer)
|
||||
resolve()
|
||||
},
|
||||
fail: () => {
|
||||
clearTimeout(timer)
|
||||
reject(new Error('文档页打开失败,请确认小程序已更新后重试'))
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
clearTimeout(timer)
|
||||
reject(error)
|
||||
}
|
||||
})
|
||||
return 'mini-program-requested' as const
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
/** 移动打印专用:Pointer Events 同时支持手指、触控笔和鼠标;移除编辑器时解绑。 */
|
||||
export function setupSealPointerDrag(editor: any) {
|
||||
const body = editor.getBody() as HTMLElement
|
||||
const doc = body.ownerDocument
|
||||
let active: {
|
||||
image: HTMLElement
|
||||
id: number
|
||||
x: number
|
||||
y: number
|
||||
left: number
|
||||
top: number
|
||||
} | null = null
|
||||
const down = (event: PointerEvent) => {
|
||||
const image = event.target as HTMLElement
|
||||
if (
|
||||
active ||
|
||||
event.button !== 0 ||
|
||||
editor.mode.get() === 'readonly' ||
|
||||
!body.contains(image) ||
|
||||
image?.tagName !== 'IMG' ||
|
||||
!image.hasAttribute('data-seal')
|
||||
)
|
||||
return
|
||||
event.preventDefault()
|
||||
active = {
|
||||
image,
|
||||
id: event.pointerId,
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
left: image.offsetLeft,
|
||||
top: image.offsetTop
|
||||
}
|
||||
image.style.cursor = 'grabbing'
|
||||
try {
|
||||
image.setPointerCapture(event.pointerId)
|
||||
} catch {
|
||||
/* iframe implementations may not capture */
|
||||
}
|
||||
}
|
||||
const move = (event: PointerEvent) => {
|
||||
if (!active || active.id !== event.pointerId) return
|
||||
event.preventDefault()
|
||||
active.image.style.left = `${active.left + event.clientX - active.x}px`
|
||||
active.image.style.top = `${active.top + event.clientY - active.y}px`
|
||||
active.image.style.right = 'auto'
|
||||
active.image.style.bottom = 'auto'
|
||||
}
|
||||
const finish = (event: PointerEvent) => {
|
||||
if (!active || active.id !== event.pointerId) return
|
||||
active.image.style.cursor = 'move'
|
||||
try {
|
||||
active.image.releasePointerCapture(event.pointerId)
|
||||
} catch {
|
||||
/* already released */
|
||||
}
|
||||
active = null
|
||||
editor.fire('change')
|
||||
}
|
||||
doc.addEventListener('pointerdown', down)
|
||||
doc.addEventListener('pointermove', move, { passive: false })
|
||||
doc.addEventListener('pointerup', finish)
|
||||
doc.addEventListener('pointercancel', finish)
|
||||
editor.on('remove', () => {
|
||||
active = null
|
||||
doc.removeEventListener('pointerdown', down)
|
||||
doc.removeEventListener('pointermove', move)
|
||||
doc.removeEventListener('pointerup', finish)
|
||||
doc.removeEventListener('pointercancel', finish)
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user