feat: 通过wangeditor自定义打印模板

pull/816/head
Lesan 2025-08-29 14:19:52 +08:00
parent 97e538539e
commit 84de7fcd46
13 changed files with 482 additions and 2 deletions

View File

@ -42,6 +42,9 @@ import Logger from '@/utils/Logger'
import VueDOMPurifyHTML from 'vue-dompurify-html' // 解决v-html 的安全隐患
// wangeditor插件注册
import {setupWangeditorPlugin} from "@/views/bpm/model/form/PrintTemplate";
// 创建实例
const setupAll = async () => {
const app = createApp(App)
@ -62,6 +65,8 @@ const setupAll = async () => {
setupAuth(app)
setupMountedFocus(app)
setupWangeditorPlugin()
await router.isReady()
app.use(VueDOMPurifyHTML)

View File

@ -1,5 +1,5 @@
<template>
<el-form ref="formRef" :model="modelData" label-width="120px" class="mt-20px">
<el-form ref="formRef" :model="modelData" label-width="130px" class="mt-20px">
<el-form-item class="mb-20px">
<template #label>
<el-text size="large" tag="b">提交人权限</el-text>
@ -231,7 +231,30 @@
/>
</div>
</el-form-item>
<el-form-item class="mb-20px">
<template #label>
<el-text size="large" tag="b">自定义打印模板</el-text>
</template>
<div class="flex flex-col w-100%">
<div class="flex">
<el-switch
v-model="modelData.printTemplateSetting.enable"
@change="handlePrintTemplateEnableChange"
/>
<el-button
v-if="modelData.printTemplateSetting.enable"
class="ml-80px"
type="primary"
link
@click="handleEditPrintTemplate"
>
编辑模板
</el-button>
</div>
</div>
</el-form-item>
</el-form>
<print-template ref="printTemplateRef" @confirm="confirmPrintTemplate"/>
</template>
<script setup lang="ts">
@ -241,6 +264,7 @@ import * as FormApi from '@/api/bpm/form'
import { parseFormFields } from '@/components/FormCreate/src/utils'
import { ProcessVariableEnum } from '@/components/SimpleProcessDesignerV2/src/consts'
import HttpRequestSetting from '@/components/SimpleProcessDesignerV2/src/nodes-config/components/HttpRequestSetting.vue'
import PrintTemplate from './PrintTemplate/Index.vue'
const modelData = defineModel<any>()
@ -436,6 +460,11 @@ const initData = () => {
if (modelData.value.allowWithdrawTask) {
modelData.value.allowWithdrawTask = false
}
if (!modelData.value.printTemplateSetting) {
modelData.value.printTemplateSetting = {
enable: false
}
}
}
defineExpose({ initData })
@ -460,4 +489,21 @@ watch(
},
{ immediate: true }
)
// TODO defaultTemplate infra_config
const defaultTemplate = '<p style="text-align: center;"><span data-w-e-type="mention" data-w-e-is-void="" data-w-e-is-inline="" data-value="流程名称" data-info="%7B%22id%22%3A%22processName%22%7D">@流程名称</span></p><p style="text-align: right;">打印人:<span data-w-e-type="mention" data-w-e-is-void="" data-w-e-is-inline="" data-value="打印人" data-info="%7B%22id%22%3A%22printUsername%22%7D">@打印人</span></p><p style="text-align: right;">流程编号:<span data-w-e-type="mention" data-w-e-is-void="" data-w-e-is-inline="" data-value="流程编号" data-info="%7B%22id%22%3A%22processNum%22%7D">@流程编号</span> &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;打印时间:<span data-w-e-type="mention" data-w-e-is-void="" data-w-e-is-inline="" data-value="打印时间" data-info="%7B%22id%22%3A%22printTime%22%7D">@打印时间</span></p><table style="width: 100%;"><tbody><tr><td colSpan="1" rowSpan="1" width="auto">发起人</td><td colSpan="1" rowSpan="1" width="auto"><span data-w-e-type="mention" data-w-e-is-void data-w-e-is-inline data-value="发起人" data-info="%7B%22id%22%3A%22startUser%22%7D">@发起人</span></td><td colSpan="1" rowSpan="1" width="auto">发起时间</td><td colSpan="1" rowSpan="1" width="auto"><span data-w-e-type="mention" data-w-e-is-void data-w-e-is-inline data-value="发起时间" data-info="%7B%22id%22%3A%22startTime%22%7D">@发起时间</span></td></tr><tr><td colSpan="1" rowSpan="1" width="auto">所属部门</td><td colSpan="1" rowSpan="1" width="auto"><span data-w-e-type="mention" data-w-e-is-void data-w-e-is-inline data-value="发起人部门" data-info="%7B%22id%22%3A%22startUserDept%22%7D">@发起人部门</span></td><td colSpan="1" rowSpan="1" width="auto">流程状态</td><td colSpan="1" rowSpan="1" width="auto"><span data-w-e-type="mention" data-w-e-is-void data-w-e-is-inline data-value="流程状态" data-info="%7B%22id%22%3A%22processStatus%22%7D">@流程状态</span></td></tr></tbody></table><p><span data-w-e-type="process-record" data-w-e-is-void data-w-e-is-inline>流程记录</span></p>'
const handlePrintTemplateEnableChange = (val: boolean) => {
if (val) {
if (!modelData.value.printTemplateSetting.template) {
modelData.value.printTemplateSetting.template = defaultTemplate
}
}
}
const printTemplateRef = ref()
const handleEditPrintTemplate = () => {
printTemplateRef.value.open(modelData.value.printTemplateSetting.template)
}
const confirmPrintTemplate = (template) => {
modelData.value.printTemplateSetting.template = template
}
</script>

View File

@ -0,0 +1,107 @@
<script setup lang="ts">
import {Editor, Toolbar} from '@wangeditor/editor-for-vue'
import {IDomEditor} from '@wangeditor/editor'
import MentionModal from "./MentionModal.vue";
const emit = defineEmits(['confirm'])
// mention
const isShowModal = ref(false)
const showModal = () => {
isShowModal.value = true
}
const hideModal = () => {
isShowModal.value = false
}
const insertMention = (id, name) => {
const mentionNode = {
type: 'mention',
value: name,
info: {id},
children: [{text: ''}],
}
const editor = editorRef.value
if (editor) {
editor.restoreSelection()
editor.deleteBackward('character')
editor.insertNode(mentionNode)
editor.move(1)
}
}
// Dialog
const dialogVisible = ref(false)
const open = async (template) => {
dialogVisible.value = true
valueHtml.value = template
}
defineExpose({open})
const handleConfirm = () => {
emit('confirm', valueHtml.value)
dialogVisible.value = false
}
// Editor
const editorRef = shallowRef<IDomEditor>()
const editorId = ref('wangeEditor-1')
const toolbarConfig = {
excludeKeys: ['group-video'],
insertKeys: {
index: 31,
keys: ['ProcessRecordMenu']
}
}
const editorConfig = {
placeholder: '请输入内容...',
EXTEND_CONF: {
mentionConfig: {
showModal,
hideModal,
},
},
}
const valueHtml = ref()
const handleCreated = (editor: IDomEditor) => {
editorRef.value = editor
}
// onBeforeUnmount
onBeforeUnmount(() => {
const editor = editorRef.value
if (editor == null) return
editor.destroy()
})
</script>
<template>
<el-dialog v-model="dialogVisible" title="自定义模板" fullscreen>
<div style="margin: 0 10px;">
<el-alert title="输入 @ 可选择插入流程表单选项和默认选项" type="info" show-icon
:closable="false"/>
</div>
<div style="border: 1px solid #ccc;margin: 10px;">
<Toolbar
style="border-bottom: 1px solid #ccc;"
:editor="editorRef"
:editorId="editorId"
:defaultConfig="toolbarConfig"
/>
<Editor
style="height: 500px; overflow-y: hidden;"
v-model="valueHtml"
:defaultConfig="editorConfig"
:editorId="editorId"
@onCreated="handleCreated"
/>
<MentionModal v-if="isShowModal"
@hideMentionModal="hideModal"
@insertMention="insertMention"/>
</div>
<div style="margin-right: 10px;float: right;">
<el-button @click="dialogVisible = false"> </el-button>
<el-button type="primary" @click="handleConfirm"> </el-button>
</div>
</el-dialog>
</template>
<style src="../../../../../../node_modules/@wangeditor/editor/dist/css/style.css"></style>

View File

@ -0,0 +1,99 @@
<script setup lang="ts">
const emit = defineEmits(['hideMentionModal', 'insertMention'])
const inputRef = ref()
const top = ref('')
const left = ref('')
const searchVal = ref('')
const list = ref([
{id: 'startUser', name: '发起人'},
{id: 'startUserDept', name: '发起人部门'},
{id: 'processName', name: '流程名称'},
{id: 'processNum', name: '流程编号'},
{id: 'startTime', name: '发起时间'},
{id: 'endTime', name: '发起时间'},
{id: 'processStatus', name: '流程状态'},
{id: 'processResult', name: '流程结果'},
{id: 'printUsername', name: '打印人'},
{id: 'printTime', name: '打印时间'},
])
const searchedList = computed(() => {
const searchValStr = searchVal.value.trim().toLowerCase()
return list.value.filter(item => {
const name = item.name.toLowerCase()
return name.indexOf(searchValStr) >= 0;
})
})
const inputKeyupHandler = (event) => {
if (event.key === 'Escape') {
emit('hideMentionModal')
}
if (event.key === 'Enter') {
const firstOne = searchedList.value[0]
if (firstOne) {
const {id, name} = firstOne
insertMentionHandler(id, name)
}
}
}
const insertMentionHandler = (id, name) => {
emit('insertMention', id, name)
emit('hideMentionModal')
}
onMounted(()=> {
const domSelection = document.getSelection()
const domRange = domSelection?.getRangeAt(0)
if (domRange == null) return
const rect = domRange.getBoundingClientRect()
top.value = `${rect.top + 20}px`
left.value = `${rect.left + 5}px`
inputRef.value.focus()
})
</script>
<template>
<div id="mention-modal" :style="{ top: top, left: left }">
<input id="mention-input" v-model="searchVal" ref="inputRef" @keyup="inputKeyupHandler" />
<ul id="mention-list">
<li
v-for="item in searchedList"
:key="item.id"
@click="insertMentionHandler(item.id, item.name)"
>{{ item.name }}
</li>
</ul>
</div>
</template>
<style>
#mention-modal {
position: absolute;
border: 1px solid #ccc;
background-color: #fff;
padding: 5px;
}
#mention-modal input {
width: 100px;
outline: none;
}
#mention-modal ul {
padding: 0;
margin: 0;
}
#mention-modal ul li {
list-style: none;
cursor: pointer;
padding: 3px 0;
text-align: left;
}
#mention-modal ul li:hover {
text-decoration: underline;
}
</style>

View File

@ -0,0 +1,9 @@
import {Boot} from '@wangeditor/editor'
import processRecordModule from "./module";
import mentionModule from "@wangeditor/plugin-mention";
// 注册。要在创建编辑器之前注册,且只能注册一次,不可重复注册。
export const setupWangeditorPlugin = () => {
Boot.registerModule(processRecordModule)
Boot.registerModule(mentionModule)
}

View File

@ -0,0 +1,12 @@
import { SlateElement } from '@wangeditor/editor'
function processRecordToHtml(elem: SlateElement, childrenHtml: string): string {
return `<span data-w-e-type="process-record" data-w-e-is-void data-w-e-is-inline>流程记录</span>`
}
const conf = {
type: 'process-record',
elemToHtml: processRecordToHtml,
}
export default conf

View File

@ -0,0 +1,16 @@
import {IModuleConf} from '@wangeditor/editor'
import withProcessRecord from './plugin'
import renderElemConf from './render-elem'
import elemToHtmlConf from './elem-to-html'
import parseHtmlConf from './parse-elem-html'
import processRecordMenu from "./menu/ProcessRecordMenu"
const module: Partial<IModuleConf> = {
editorPlugin: withProcessRecord,
renderElems: [renderElemConf],
elemsToHtml: [elemToHtmlConf],
parseElemsHtml: [parseHtmlConf],
menus: [processRecordMenu],
}
export default module

View File

@ -0,0 +1,42 @@
import { IButtonMenu, IDomEditor } from '@wangeditor/editor'
class ProcessRecordMenu implements IButtonMenu {
readonly tag: string;
readonly title: string;
constructor() {
this.title = '流程记录'
this.tag = 'button'
}
getValue(editor: IDomEditor): string {
return ''
}
isActive(editor: IDomEditor): boolean {
return false
}
isDisabled(editor: IDomEditor): boolean {
return false
}
exec(editor: IDomEditor, value: string) {
if (this.isDisabled(editor)) return
const processRecordElem = {
type: 'process-record',
children: [{ text: '' }],
}
editor.insertNode(processRecordElem)
editor.move(1)
}
}
const ProcessRecordMenuConf = {
key: 'ProcessRecordMenu',
factory() {
return new ProcessRecordMenu()
}
}
export default ProcessRecordMenuConf

View File

@ -0,0 +1,20 @@
import { DOMElement } from './utils/dom'
import { IDomEditor, SlateDescendant, SlateElement } from '@wangeditor/editor'
function parseHtml(
elem: DOMElement,
children: SlateDescendant[],
editor: IDomEditor
): SlateElement {
return {
type: 'process-record',
children: [{ text: '' }],
}
}
const parseHtmlConf = {
selector: 'span[data-w-e-type="process-record"]',
parseElemHtml: parseHtml,
}
export default parseHtmlConf

View File

@ -0,0 +1,28 @@
import { DomEditor, IDomEditor } from '@wangeditor/editor'
function withProcessRecord<T extends IDomEditor>(editor: T) {
const { isInline, isVoid } = editor
const newEditor = editor
newEditor.isInline = elem => {
const type = DomEditor.getNodeType(elem)
if (type === 'process-record') {
return true
}
return isInline(elem)
}
newEditor.isVoid = elem => {
const type = DomEditor.getNodeType(elem)
if (type === 'process-record') {
return true
}
return isVoid(elem)
}
return newEditor
}
export default withProcessRecord

View File

@ -0,0 +1,72 @@
import {h, VNode} from 'snabbdom'
import {DomEditor, IDomEditor, SlateElement} from '@wangeditor/editor'
function renderProcessRecord(elem: SlateElement, children: VNode[] | null, editor: IDomEditor): VNode {
const selected = DomEditor.isNodeSelected(editor, elem)
const vnode = h(
'table',
{
props: {
contentEditable: false,
},
style: {
width: '100%',
border: selected
? '2px solid var(--w-e-textarea-selected-border-color)'
: '',
},
},
[
h('thead', [
h('tr', [h('th', {attrs: {colSpan: 3}}, '流程记录')])
]),
h('tbody', [
h('tr', [
h('td', [h(
'span',
{
props: {
contentEditable: false,
},
style: {
marginLeft: '3px',
marginRight: '3px',
backgroundColor: 'var(--w-e-textarea-slight-bg-color)',
borderRadius: '3px',
padding: '0 3px',
},
},
`节点`
)
]),
h('td', [h(
'span',
{
props: {
contentEditable: false,
},
style: {
marginLeft: '3px',
marginRight: '3px',
backgroundColor: 'var(--w-e-textarea-slight-bg-color)',
borderRadius: '3px',
padding: '0 3px',
},
},
`操作`
)
])
])
])
]
)
return vnode
}
const conf = {
type: 'process-record',
renderElem: renderProcessRecord,
}
export default conf

View File

@ -0,0 +1,21 @@
import $, { append, on, hide, click } from 'dom7'
if (hide) $.fn.hide = hide
if (append) $.fn.append = append
if (click) $.fn.click = click
if (on) $.fn.on = on
export { Dom7Array } from 'dom7'
export default $
// COMPAT: This is required to prevent TypeScript aliases from doing some very
// weird things for Slate's types with the same name as globals. (2019/11/27)
// https://github.com/microsoft/TypeScript/issues/35002
import DOMNode = globalThis.Node
import DOMComment = globalThis.Comment
import DOMElement = globalThis.Element
import DOMText = globalThis.Text
import DOMRange = globalThis.Range
import DOMSelection = globalThis.Selection
import DOMStaticRange = globalThis.StaticRange
export { DOMNode, DOMComment, DOMElement, DOMText, DOMRange, DOMSelection, DOMStaticRange }

View File

@ -174,7 +174,10 @@ const formData: any = ref({
enable: false,
summary: []
},
allowWithdrawTask: false
allowWithdrawTask: false,
printTemplateSetting: {
enable: false
}
})
//