Pre Merge pull request !816 from Lesan/feature/bpm-打印

pull/816/MERGE
Lesan 2025-09-03 01:20:24 +00:00 committed by Gitee
commit 7479afbe85
No known key found for this signature in database
GPG Key ID: 173E9B9CA92EEF8F
17 changed files with 628 additions and 3 deletions

View File

@ -34,6 +34,7 @@
"@vueuse/core": "^10.9.0",
"@wangeditor/editor": "^5.1.23",
"@wangeditor/editor-for-vue": "^5.1.10",
"@wangeditor/plugin-mention": "^1.0.0",
"@zxcvbn-ts/core": "^3.0.4",
"animate.css": "^4.1.1",
"axios": "1.9.0",
@ -65,6 +66,7 @@
"pinia-plugin-persistedstate": "^3.2.1",
"qrcode": "^1.5.3",
"qs": "^6.12.0",
"snabbdom": "^3.6.2",
"sortablejs": "^1.15.3",
"steady-xml": "^0.1.0",
"url": "^0.11.3",
@ -74,6 +76,7 @@
"vue-i18n": "9.10.2",
"vue-router": "4.4.5",
"vue-types": "^5.1.1",
"vue3-print-nb": "^0.1.4",
"vue3-signature": "^0.2.4",
"vuedraggable": "^4.1.0",
"web-storage-cache": "^1.1.1",

View File

@ -108,3 +108,8 @@ export const getFormFieldsPermission = async (params: any) => {
export const getProcessInstanceBpmnModelView = async (id: string) => {
return await request.get({ url: '/bpm/process-instance/get-bpmn-model-view?id=' + id })
}
// 获取流程实例打印数据
export const getProcessInstancePrintData = async (id: string) => {
return await request.get({ url: '/bpm/process-instance/get-print-data?processInstanceId=' + id })
}

View File

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

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>()
@ -394,6 +418,7 @@ const formFieldOptions4Summary = computed(() => {
const unParsedFormFields = ref<string[]>([])
/** 暴露给子组件 HttpRequestSetting 使用 */
provide('formFields', unParsedFormFields)
provide('formFieldsObj', formFields)
/** 兼容以前未配置更多设置的流程 */
const initData = () => {
@ -436,6 +461,11 @@ const initData = () => {
if (modelData.value.allowWithdrawTask) {
modelData.value.allowWithdrawTask = false
}
if (!modelData.value.printTemplateSetting) {
modelData.value.printTemplateSetting = {
enable: false
}
}
}
defineExpose({ initData })
@ -460,4 +490,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%22printUser%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,112 @@
<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
console.log(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"
@on-created="handleCreated"
/>
<MentionModal
v-if="isShowModal"
@hide-mention-modal="hideModal"
@insert-mention="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="@wangeditor/editor/dist/css/style.css"></style>

View File

@ -0,0 +1,109 @@
<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: 'printUser', 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')
}
const formFields = inject('formFieldsObj')
onMounted(()=> {
if (formFields.value && formFields.value.length > 0) {
const cloneFormField = formFields.value.map((item) => {
return {
name: '[表单]'+item.title,
id: item.field
}
})
list.value.push(...cloneFormField)
}
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
}
})
//

View File

@ -0,0 +1,107 @@
<script setup lang="ts">
import * as ProcessInstanceApi from '@/api/bpm/processInstance'
import { useUserStore } from '@/store/modules/user'
import { formatDate } from '@/utils/formatTime'
const userStore = useUserStore()
const visible = ref(false)
const loading = ref(false)
const printData = ref()
const userName = computed(() => userStore.user.nickname ?? '')
const printTime = ref(formatDate(new Date(), 'YYYY-MM-DD HH:mm'))
const open = async (id) => {
loading.value = true
try {
printData.value = await ProcessInstanceApi.getProcessInstancePrintData(id)
console.log(printData.value)
} finally {
loading.value = false
}
visible.value = true
}
defineExpose({ open })
const printObj = ref({
id: 'printDivTag',
popTitle: '&nbsp',
extraCss: '/print.css',
extraHead: '',
zIndex: 20003
})
</script>
<template>
<el-dialog v-loading="loading" v-model="visible" :show-close="false">
<div id="printDivTag">
<div v-if="printData.printTemplateEnable" v-html="printData.printTemplateHtml"></div>
<div v-else>
<h2 class="text-center">{{ printData.processName }}</h2>
<div class="text-right text-15px">{{ '打印人员: ' + userName }}</div>
<div class="flex justify-between">
<div class="text-15px">{{ '流程编号: ' + printData.processInstanceId }}</div>
<div class="text-15px">{{ '打印时间: ' + printTime }}</div>
</div>
<table class="mt-20px w-100%" border="1" style="border-collapse: collapse">
<tbody>
<tr>
<td class="p-5px w-25%">发起人</td>
<td class="p-5px w-25%">{{ printData.startUser }}</td>
<td class="p-5px w-25%">发起时间</td>
<td class="p-5px w-25%">{{ printData.startTime }}</td>
</tr>
<tr>
<td class="p-5px w-25%">所属部门</td>
<td class="p-5px w-25%">{{ printData.startUserDept }}</td>
<td class="p-5px w-25%">流程状态</td>
<td class="p-5px w-25%">{{ printData.processStatusShow }}</td>
</tr>
<tr>
<td class="p-5px w-100% text-center" colspan="4">
<h4>表单内容</h4>
</td>
</tr>
<tr v-for="item in printData.formFields" :key="item.formId">
<td class="p-5px w-20%">
{{ item.formName }}
</td>
<td class="p-5px w-80%" colspan="3">
<div v-html="item.formValueShow"></div>
</td>
</tr>
<tr>
<td class="p-5px w-100% text-center" colspan="4">
<h4>流程节点</h4>
</td>
</tr>
<tr v-for="item in printData.approveNodes" :key="item.nodeId">
<td class="p-5px w-20%">
{{ item.nodeName }}
</td>
<td class="p-5px w-80%" colspan="3">
{{ item.nodeDesc }}
<div v-if="item.signUrl !== ''">
<img class="w-90px h-40px" :src="item.signUrl" alt="" />
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<template #footer>
<div class="dialog-footer">
<el-button @click="visible = false"> </el-button>
<el-button type="primary" v-print="printObj"> </el-button>
</div>
</template>
</el-dialog>
</template>
<style lang="scss">
table {
border-collapse: collapse;
}
</style>

View File

@ -8,7 +8,10 @@
:src="auditIconsMap[processInstance.status]"
alt=""
/>
<div class="text-#878c93 h-15px">编号{{ id }}</div>
<div class="flex">
<div class="text-#878c93 h-15px">编号{{ id }}</div>
<Icon icon="ep:printer" class="ml-15px cursor-pointer" @click="handlePrint"/>
</div>
<el-divider class="!my-8px" />
<div class="flex items-center gap-5 mb-10px h-40px">
<div class="text-26px font-bold mb-5px">{{ processInstance.name }}</div>
@ -125,6 +128,7 @@
</el-scrollbar>
</div>
</ContentWrap>
<PrintDialog ref="printRef" />
</template>
<script lang="ts" setup>
import { formatDate } from '@/utils/formatTime'
@ -146,6 +150,7 @@ import runningSvg from '@/assets/svgs/bpm/running.svg'
import approveSvg from '@/assets/svgs/bpm/approve.svg'
import rejectSvg from '@/assets/svgs/bpm/reject.svg'
import cancelSvg from '@/assets/svgs/bpm/cancel.svg'
import PrintDialog from './PrintDialog.vue'
defineOptions({ name: 'BpmProcessInstanceDetail' })
const props = defineProps<{
@ -295,6 +300,11 @@ const refresh = () => {
getDetail()
}
const printRef = ref()
const handlePrint = async () => {
printRef.value.open(props.id)
}
/** 当前的Tab */
const activeTab = ref('form')