fix(bpm): 完善流程实例打印字段展示

- Vben5 web-antd/web-ele/web-antdv-next 同步支持更多表单字段打印
- Vue3 + Element Plus 流程打印补齐字典、用户、部门、省市区、文件、图片、开关、富文本等字段展示
- 普通字段和流程记录改为安全文本渲染,保留富文本 HTML 展示
- 打印时间改为每次打开弹窗时刷新
- web-ele 补充 ElButton 显式导入,避免运行时组件解析风险
master
YunaiV 2026-05-31 18:22:15 +08:00
parent 595b8c5bb4
commit ff3c7884cc
1 changed files with 359 additions and 50 deletions

View File

@ -1,10 +1,53 @@
<script setup lang="ts">
import * as ProcessInstanceApi from '@/api/bpm/processInstance'
import * as AreaApi from '@/api/system/area'
import * as DeptApi from '@/api/system/dept'
import * as UserApi from '@/api/system/user'
import { useUserStore } from '@/store/modules/user'
import { formatDate } from '@/utils/formatTime'
import { DICT_TYPE, getDictLabel } from '@/utils/dict'
import {
DICT_TYPE,
getBoolDictOptions,
getDictLabel,
getDictOptions,
getIntDictOptions,
getStrDictOptions
} from '@/utils/dict'
import { decodeFields } from '@/utils/formCreate'
interface FormFieldItem {
html: string
id: string
name: string
}
interface FormFieldOption {
label?: string
value?: unknown
}
type FormFieldRule = Record<string, unknown> & {
field?: string
options?: FormFieldOption[]
props?: Record<string, unknown>
title?: string
type?: string
}
type PrintableRecord = Record<string, unknown>
interface AreaNode {
children?: AreaNode[]
id?: number
name: string
}
interface PrintLookupMaps {
areaMap: Map<string, string>
deptMap: Map<string, string>
userMap: Map<string, string>
}
const userStore = useUserStore()
const visible = ref(false)
@ -13,15 +56,16 @@ 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 formFields = ref()
const printDataMap = ref({})
const formFields = ref<FormFieldItem[]>([])
const printDataMap = ref<Record<string, string>>({})
const open = async (id: string) => {
loading.value = true
try {
printData.value = await ProcessInstanceApi.getProcessInstancePrintData(id)
printTime.value = formatDate(new Date(), 'YYYY-MM-DD HH:mm')
initPrintDataMap()
parseFormFields()
await parseFormFields()
} finally {
loading.value = false
}
@ -29,82 +73,347 @@ const open = async (id: string) => {
}
defineExpose({ open })
const parseFormFields = () => {
const parseFormFields = async () => {
if (!printData.value) return
const formFieldsObj = decodeFields(
printData.value.processInstance.processDefinition?.formFields || []
)
const processVariables = printData.value.processInstance.formVariables
let res: any = []
) as FormFieldRule[]
const processVariables = printData.value.processInstance.formVariables ?? {}
const lookupMaps = await loadPrintLookupMaps(formFieldsObj)
const res: FormFieldItem[] = []
for (const item of formFieldsObj) {
const id = item['field']
const name = item['title']
const variable = processVariables[item['field']]
let html = variable
switch (item['type']) {
case 'UploadImg': {
let imgEl = document.createElement('img')
imgEl.setAttribute('src', variable)
imgEl.setAttribute('style', 'max-width: 600px;')
html = imgEl.outerHTML
break
}
case 'radio':
case 'checkbox':
case 'select': {
const options = item['options'] || []
const temp: any = []
if (Array.isArray(variable)) {
const labels = options.filter((o) => variable.includes(o.value)).map((o) => o.label)
temp.push(...labels)
} else {
const opt = options.find((o) => o.value === variable)
temp.push(opt.label)
}
html = temp.join(',')
}
// TODO
}
printDataMap.value[item['field']] = html
const fieldKey = String(item.field ?? '')
const id = fieldKey
const name = String(item.title ?? fieldKey)
const variable = processVariables[fieldKey]
const html = formatPrintField(item, variable, lookupMaps)
printDataMap.value[fieldKey] = html
res.push({ id, name, html })
}
formFields.value = res
}
const getRuleProp = (rule: FormFieldRule, key: string) => {
return rule?.[key] ?? rule?.props?.[key]
}
const isPrintableRecord = (value: unknown): value is PrintableRecord => {
return typeof value === 'object' && value !== null
}
const getRecordValue = (record: PrintableRecord, key: string) => {
return record[key]
}
const isNotEmptyString = (value: string) => value.length > 0
const isEmptyValue = (value: unknown) => value === undefined || value === null || value === ''
const toValueArray = (value: unknown) => {
if (Array.isArray(value)) {
return value
}
if (isEmptyValue(value)) {
return []
}
return [value]
}
const escapeHtml = (value: unknown) => {
return String(value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
}
const tryFormatDate = (value: unknown) => {
if (isEmptyValue(value)) {
return ''
}
const formatted = formatDate(value as Date | number | string)
return formatted === 'Invalid Date' ? escapeHtml(value) : formatted
}
const formatDateValue = (value: unknown) => {
if (Array.isArray(value)) {
return value.map((item) => tryFormatDate(item)).join(' ~ ')
}
return tryFormatDate(value)
}
const formatPrimitiveValue = (value: unknown): string => {
if (isEmptyValue(value)) {
return ''
}
if (Array.isArray(value)) {
return value
.map((item) => formatPrimitiveValue(item))
.filter((s) => isNotEmptyString(s))
.join(', ')
}
if (typeof value === 'boolean') {
return value ? '是' : '否'
}
if (isPrintableRecord(value)) {
const displayValue =
getRecordValue(value, 'label') ??
getRecordValue(value, 'name') ??
getRecordValue(value, 'url') ??
getRecordValue(value, 'value') ??
JSON.stringify(value)
return escapeHtml(displayValue)
}
return escapeHtml(value)
}
const createImageHtml = (url: string) => {
const imgEl = document.createElement('img')
imgEl.setAttribute('src', url)
imgEl.setAttribute('style', 'max-width: 600px; max-height: 300px;')
return imgEl.outerHTML
}
const renderImageListHtml = (value: unknown) => {
return toValueArray(value)
.map((item) => {
let url: string | undefined
if (typeof item === 'string') {
url = item
} else if (isPrintableRecord(item)) {
const recordUrl = getRecordValue(item, 'url')
url = recordUrl ? String(recordUrl) : undefined
}
return url ? createImageHtml(url) : ''
})
.filter((s) => isNotEmptyString(s))
.join('<br/>')
}
const createFileLinkHtml = (file: unknown) => {
const record = isPrintableRecord(file) ? file : undefined
const recordUrl = record ? getRecordValue(record, 'url') : undefined
const url = typeof file === 'string' ? file : String(recordUrl ?? '')
if (!url) {
return ''
}
const linkEl = document.createElement('a')
linkEl.setAttribute('href', url)
linkEl.setAttribute('target', '_blank')
linkEl.setAttribute('rel', 'noopener noreferrer')
const fallbackName = url.slice(Math.max(0, url.lastIndexOf('/') + 1)) || url
const recordName = record ? getRecordValue(record, 'name') : undefined
linkEl.textContent = recordName ? String(recordName) : fallbackName
return linkEl.outerHTML
}
const renderFileListHtml = (value: unknown) => {
return toValueArray(value)
.map((item) => createFileLinkHtml(item))
.filter((s) => isNotEmptyString(s))
.join('<br/>')
}
const mapValuesWithOptions = (value: unknown, options: FormFieldOption[] = []) => {
const values = toValueArray(value)
const labels = values
.map((item) => {
const matched = options.find(
(option) => option?.value === item || String(option?.value ?? '') === String(item)
)
return escapeHtml(matched?.label ?? String(item))
})
.filter((s) => isNotEmptyString(s))
return labels.join(', ')
}
const flattenAreaTree = (list: AreaNode[] = [], map: Map<string, string> = new Map()) => {
list.forEach((item) => {
if (item.id !== undefined) {
map.set(String(item.id), item.name)
}
if (Array.isArray(item.children) && item.children.length > 0) {
flattenAreaTree(item.children, map)
}
})
return map
}
const mapValueWithLabelMap = (
value: unknown,
labelMap: Map<string, string>,
separator = ', '
) => {
const values = toValueArray(value)
const labels = values
.map((item) => escapeHtml(labelMap.get(String(item)) ?? String(item)))
.filter((s) => isNotEmptyString(s))
return labels.length > 0 ? labels.join(escapeHtml(separator)) : formatPrimitiveValue(values)
}
const getTypedDictOptions = (dictType: string, valueType: string) => {
switch (valueType) {
case 'bool':
case 'boolean':
return getBoolDictOptions(dictType)
case 'int':
case 'number':
return getIntDictOptions(dictType)
case 'str':
case 'string':
return getStrDictOptions(dictType)
default:
return getDictOptions(dictType)
}
}
const loadPrintLookupMaps = async (formFieldsObj: FormFieldRule[]) => {
const hasAreaSelect = formFieldsObj.some((item) => item.type === 'AreaSelect')
const hasUserSelect = formFieldsObj.some((item) => item.type === 'UserSelect')
const hasDeptSelect = formFieldsObj.some((item) => item.type === 'DeptSelect')
const [areaList, userList, deptList] = await Promise.all([
hasAreaSelect ? AreaApi.getAreaTree() : Promise.resolve([]),
hasUserSelect ? UserApi.getSimpleUserList() : Promise.resolve([]),
hasDeptSelect ? DeptApi.getSimpleDeptList() : Promise.resolve([])
])
return {
areaMap: flattenAreaTree(areaList as AreaNode[]),
deptMap: new Map((deptList ?? []).map((item) => [String(item.id), item.name] as const)),
userMap: new Map(
(userList ?? []).map((item) => [String(item.id), item.nickname ?? item.username] as const)
)
} satisfies PrintLookupMaps
}
const formatPrintField = (
rule: FormFieldRule,
value: unknown,
lookupMaps: PrintLookupMaps
) => {
const type = String(rule.type ?? '')
switch (type) {
case 'AreaSelect': {
const separator = String(getRuleProp(rule, 'separator') || '/')
return mapValueWithLabelMap(value, lookupMaps.areaMap, separator)
}
case 'cascader':
case 'checkbox':
case 'radio':
case 'select':
case 'treeSelect': {
const options = getRuleProp(rule, 'options')
return Array.isArray(options) && options.length > 0
? mapValuesWithOptions(value, options as FormFieldOption[])
: formatPrimitiveValue(value)
}
case 'date':
case 'DatePicker':
case 'datePicker':
case 'daterange':
case 'datetime':
case 'datetimerange':
case 'month':
case 'monthrange':
case 'RangePicker':
case 'rangePicker':
case 'TimePicker':
case 'timePicker':
case 'TimeRangePicker':
case 'timeRangePicker':
return formatDateValue(value)
case 'DeptSelect': {
if (String(getRuleProp(rule, 'returnType')) === 'name') {
return formatPrimitiveValue(value)
}
return mapValueWithLabelMap(value, lookupMaps.deptMap)
}
case 'DictSelect': {
const dictType = getRuleProp(rule, 'dictType')
if (typeof dictType !== 'string' || !dictType) {
return formatPrimitiveValue(value)
}
const valueType = String(getRuleProp(rule, 'valueType') ?? '')
return mapValuesWithOptions(value, getTypedDictOptions(dictType, valueType))
}
case 'FileUpload':
case 'UploadFile':
return renderFileListHtml(value)
case 'IframeComponent': {
const propsObj = rule.props
const propsUrl = isPrintableRecord(propsObj) ? String(getRecordValue(propsObj, 'url') ?? '') : ''
const iframeUrl = isEmptyValue(value) ? propsUrl : String(value ?? '')
return iframeUrl ? createFileLinkHtml(iframeUrl) : ''
}
case 'ImagesUpload':
case 'ImageUpload':
case 'UploadImg':
case 'UploadImgs':
return renderImageListHtml(value)
case 'switch': {
if (isEmptyValue(value)) return '否'
const checkedVal = getRuleProp(rule, 'checkedValue') ?? getRuleProp(rule, 'activeValue')
const isChecked =
checkedVal !== undefined && checkedVal !== null ? value === checkedVal : Boolean(value)
return isChecked ? '是' : '否'
}
case 'Editor':
case 'Tinymce':
return isEmptyValue(value) ? '' : String(value)
case 'UserSelect': {
if (String(getRuleProp(rule, 'returnType')) === 'name') {
return formatPrimitiveValue(value)
}
return mapValueWithLabelMap(value, lookupMaps.userMap)
}
default:
return formatPrimitiveValue(value)
}
}
const initPrintDataMap = () => {
printDataMap.value['startUser'] = printData.value.processInstance.startUser.nickname
printDataMap.value['startUserDept'] = printData.value.processInstance.startUser.deptName
if (!printData.value) return
printDataMap.value['startUser'] = printData.value.processInstance.startUser?.nickname || ''
printDataMap.value['startUserDept'] = printData.value.processInstance.startUser?.deptName || ''
printDataMap.value['processName'] = printData.value.processInstance.name
printDataMap.value['processNum'] = printData.value.processInstance.id
printDataMap.value['processNum'] = String(printData.value.processInstance.id ?? '')
printDataMap.value['startTime'] = formatDate(printData.value.processInstance.startTime)
printDataMap.value['endTime'] = formatDate(printData.value.processInstance.endTime)
printDataMap.value['processStatus'] = getDictLabel(
printDataMap.value['processStatus'] = String(getDictLabel(
DICT_TYPE.BPM_PROCESS_INSTANCE_STATUS,
printData.value.processInstance.status
)
) ?? '')
printDataMap.value['printUser'] = userName.value
printDataMap.value['printTime'] = printTime.value
}
const getPrintTemplateHTML = () => {
if (!printData.value?.printTemplateHtml) return ''
const parser = new DOMParser()
let doc = parser.parseFromString(printData.value.printTemplateHtml, 'text/html')
const doc = parser.parseFromString(printData.value.printTemplateHtml, 'text/html')
// table border
let tables = doc.querySelectorAll('table')
const tables = doc.querySelectorAll('table')
tables.forEach((item) => {
item.setAttribute('border', '1')
item.setAttribute('style', (item.getAttribute('style') || '') + 'border-collapse:collapse;')
})
// mentions
let mentions = doc.querySelectorAll('[data-w-e-type="mention"]')
const mentions = doc.querySelectorAll('[data-w-e-type="mention"]')
mentions.forEach((item) => {
const mentionId = JSON.parse(decodeURIComponent(item.getAttribute('data-info') ?? ''))['id']
item.innerHTML = printDataMap.value[mentionId] ?? ''
})
//
let processRecords = doc.querySelectorAll('[data-w-e-type="process-record"]')
let processRecordTable: Element = document.createElement('table')
const processRecords = doc.querySelectorAll('[data-w-e-type="process-record"]')
const processRecordTable: Element = document.createElement('table')
if (processRecords.length > 0) {
// html
processRecordTable.setAttribute('border', '1')
@ -114,15 +423,15 @@ const getPrintTemplateHTML = () => {
headTd.setAttribute('colspan', '2')
headTd.setAttribute('width', 'auto')
headTd.setAttribute('style', 'text-align: center;')
headTd.innerHTML = '流程节点'
headTd.textContent = '流程节点'
headTr.appendChild(headTd)
processRecordTable.appendChild(headTr)
printData.value.tasks.forEach((item) => {
const tr = document.createElement('tr')
const td1 = document.createElement('td')
td1.innerHTML = item.name
td1.textContent = item.name
const td2 = document.createElement('td')
td2.innerHTML = item.description
td2.textContent = item.description
tr.appendChild(td1)
tr.appendChild(td2)
processRecordTable.appendChild(tr)