浏览代码

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

芋道源码 5 月之前
父节点
当前提交
1c6e6eb24e

+ 3 - 0
package.json

@@ -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",

+ 5 - 0
src/api/bpm/processInstance/index.ts

@@ -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 })
+}

+ 9 - 0
src/main.ts

@@ -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')
 }
 

文件差异内容过多而无法显示
+ 48 - 1
src/views/bpm/model/form/ExtraSettings.vue


+ 112 - 0
src/views/bpm/model/form/PrintTemplate/Index.vue

@@ -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>

+ 109 - 0
src/views/bpm/model/form/PrintTemplate/MentionModal.vue

@@ -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>

+ 9 - 0
src/views/bpm/model/form/PrintTemplate/index.ts

@@ -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)
+}

+ 12 - 0
src/views/bpm/model/form/PrintTemplate/module/elem-to-html.ts

@@ -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

+ 16 - 0
src/views/bpm/model/form/PrintTemplate/module/index.ts

@@ -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

+ 42 - 0
src/views/bpm/model/form/PrintTemplate/module/menu/ProcessRecordMenu.ts

@@ -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

+ 20 - 0
src/views/bpm/model/form/PrintTemplate/module/parse-elem-html.ts

@@ -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

+ 28 - 0
src/views/bpm/model/form/PrintTemplate/module/plugin.ts

@@ -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

+ 72 - 0
src/views/bpm/model/form/PrintTemplate/module/render-elem.ts

@@ -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

+ 21 - 0
src/views/bpm/model/form/PrintTemplate/module/utils/dom.ts

@@ -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 }

+ 4 - 1
src/views/bpm/model/form/index.vue

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

+ 107 - 0
src/views/bpm/processInstance/detail/PrintDialog.vue

@@ -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>

+ 11 - 1
src/views/bpm/processInstance/detail/index.vue

@@ -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')