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

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

View File

@ -90,6 +90,7 @@ const [Modal, modalApi] = useVbenModal({
/** 获取打印数据 */
async function fetchPrintData(id: string) {
printData.value = await getProcessInstancePrintData(id);
printTime.value = formatDate(new Date(), 'YYYY-MM-DD HH:mm');
initPrintDataMap();
await parseFormFields();
}
@ -154,7 +155,7 @@ function tryFormatDate(value: unknown) {
return '';
}
const formatted = formatDate(value as Date | number | string);
return formatted === 'Invalid Date' ? String(value) : formatted;
return formatted === 'Invalid Date' ? escapeHtml(value) : formatted;
}
function formatDateValue(value: unknown) {
@ -164,6 +165,15 @@ function formatDateValue(value: unknown) {
return tryFormatDate(value);
}
function escapeHtml(value: unknown) {
return String(value)
.replaceAll('&', '&')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
}
function formatPrimitiveValue(value: unknown): string {
if (isEmptyValue(value)) {
return '';
@ -185,9 +195,9 @@ function formatPrimitiveValue(value: unknown): string {
getRecordValue(record, 'url') ??
getRecordValue(record, 'value') ??
JSON.stringify(value);
return String(displayValue);
return escapeHtml(displayValue);
}
return String(value);
return escapeHtml(value);
}
function createImageHtml(url: string) {
@ -248,7 +258,7 @@ function mapValuesWithOptions(
(option) =>
option?.value === item || String(option?.value ?? '') === String(item),
);
return matched?.label ?? String(item);
return escapeHtml(matched?.label ?? String(item));
})
.filter((s) => isNotEmptyString(s));
return labels.join(', ');
@ -276,9 +286,11 @@ function mapValueWithLabelMap(
) {
const values = toValueArray(value);
const labels = values
.map((item) => labelMap.get(String(item)) ?? String(item))
.map((item) => escapeHtml(labelMap.get(String(item)) ?? String(item)))
.filter((s) => isNotEmptyString(s));
return labels.length > 0 ? labels.join(separator) : formatPrimitiveValue(values);
return labels.length > 0
? labels.join(escapeHtml(separator))
: formatPrimitiveValue(values);
}
/**
@ -380,7 +392,12 @@ function formatPrintField(
const options = getDictOptions(dictType, valueType);
return mapValuesWithOptions(value, options);
}
case 'FileUpload': {
case 'Editor':
case 'Tinymce': {
return isEmptyValue(value) ? '' : String(value);
}
case 'FileUpload':
case 'UploadFile': {
return renderFileListHtml(value);
}
case 'IframeComponent': {
@ -394,21 +411,20 @@ function formatPrintField(
}
case 'ImagesUpload':
case 'ImageUpload':
case 'UploadImg': {
case 'UploadImg':
case 'UploadImgs': {
return renderImageListHtml(value);
}
case 'switch': {
if (isEmptyValue(value)) return '否';
const checkedVal = getRuleProp(rule, 'checkedValue');
const checkedVal =
getRuleProp(rule, 'checkedValue') ?? getRuleProp(rule, 'activeValue');
const isChecked =
checkedVal !== undefined && checkedVal !== null
? value === checkedVal
: Boolean(value);
return isChecked ? '是' : '否';
}
case 'Tinymce': {
return isEmptyValue(value) ? '' : String(value);
}
case 'UserSelect': {
if (String(getRuleProp(rule, 'returnType')) === 'name') {
return formatPrimitiveValue(value);
@ -481,7 +497,7 @@ function getPrintTemplateHTML() {
const headTd = document.createElement('td');
headTd.setAttribute('colspan', '2');
headTd.setAttribute('class', 'border border-black p-1.5 text-center');
headTd.innerHTML = '流程记录';
headTd.textContent = '流程记录';
headTr.append(headTd);
processRecordTable.append(headTr);
@ -489,10 +505,10 @@ function getPrintTemplateHTML() {
const tr = document.createElement('tr');
const td1 = document.createElement('td');
td1.setAttribute('class', 'border border-black p-1.5');
td1.innerHTML = item.name;
td1.textContent = item.name;
const td2 = document.createElement('td');
td2.setAttribute('class', 'border border-black p-1.5');
td2.innerHTML = item.description;
td2.textContent = item.description;
tr.append(td1);
tr.append(td2);
processRecordTable.append(tr);

View File

@ -1,27 +1,59 @@
<script setup lang="ts">
import type { BpmProcessInstanceApi } from '#/api/bpm/processInstance';
import type { SystemAreaApi } from '#/api/system/area';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { DICT_TYPE } from '@vben/constants';
import { getDictLabel } from '@vben/hooks';
import { getDictLabel, getDictOptions } from '@vben/hooks';
import { useUserStore } from '@vben/stores';
import { formatDate } from '@vben/utils';
import { ElButton } from 'element-plus';
// @ts-expect-error - vue3-print-nb v-print
import vPrint from 'vue3-print-nb';
import { getProcessInstancePrintData } from '#/api/bpm/processInstance';
import { getAreaTree } from '#/api/system/area';
import { getSimpleDeptList } from '#/api/system/dept';
import { getSimpleUserList } from '#/api/system/user';
import { decodeFields } from '#/components/form-create';
const userStore = useUserStore();
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 PrintLookupMaps {
areaMap: Map<string, string>;
deptMap: Map<string, string>;
userMap: Map<string, string>;
}
const printData = ref<BpmProcessInstanceApi.ProcessPrintDataRespVO>();
const userName = computed(() => userStore.userInfo?.nickname ?? '');
const printTime = ref(formatDate(new Date(), 'YYYY-MM-DD HH:mm'));
const formFields = ref<any[]>([]);
const printDataMap = ref<Record<string, any>>({});
const formFields = ref<FormFieldItem[]>([]);
const printDataMap = ref<Record<string, string>>({});
/** 打印配置 */
const printObj = ref({
@ -58,58 +90,28 @@ const [Modal, modalApi] = useVbenModal({
/** 获取打印数据 */
async function fetchPrintData(id: string) {
printData.value = await getProcessInstancePrintData(id);
printTime.value = formatDate(new Date(), 'YYYY-MM-DD HH:mm');
initPrintDataMap();
parseFormFields();
await parseFormFields();
}
/** 解析表单字段 */
function parseFormFields() {
async function parseFormFields() {
if (!printData.value) return;
const formFieldsObj = decodeFields(
printData.value.processInstance.processDefinition?.formFields || [],
);
const processVariables = printData.value.processInstance.formVariables;
const res: any = [];
) as unknown 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 fieldKey = item.field as string;
const fieldKey = String(item.field ?? '');
const id = fieldKey;
const name = String(item.title ?? fieldKey);
const variable = processVariables[fieldKey];
let html = variable;
switch (item.type) {
case 'checkbox':
case 'radio':
case 'select': {
const options = item.options;
const temp: any = [];
if (Array.isArray(options)) {
if (Array.isArray(variable)) {
const labels = options
.filter((o: any) => variable.includes(o.value))
.map((o: any) => o.label);
temp.push(...labels);
} else {
const opt = options.find((o: any) => o.value === variable);
if (opt) {
temp.push(opt.label);
}
}
}
html = temp.join(',');
break;
}
case 'UploadImg': {
const imgEl = document.createElement('img');
imgEl.setAttribute('src', variable);
imgEl.setAttribute('style', 'max-width: 600px;');
html = imgEl.outerHTML;
break;
}
// TODO
}
const html = formatPrintField(item, variable, lookupMaps);
printDataMap.value[fieldKey] = html;
res.push({ id, name, html });
@ -118,6 +120,323 @@ function parseFormFields() {
formFields.value = res;
}
function getRuleProp(rule: FormFieldRule, key: string) {
return rule?.[key] ?? rule?.props?.[key];
}
function isPrintableRecord(value: unknown): value is PrintableRecord {
return typeof value === 'object' && value !== null;
}
function getRecordValue(record: PrintableRecord, key: string) {
return record[key];
}
function isNotEmptyString(value: string) {
return value.length > 0;
}
function isEmptyValue(value: unknown) {
return value === undefined || value === null || value === '';
}
function toValueArray(value: unknown) {
if (Array.isArray(value)) {
return value;
}
if (isEmptyValue(value)) {
return [];
}
return [value];
}
function tryFormatDate(value: unknown) {
if (isEmptyValue(value)) {
return '';
}
const formatted = formatDate(value as Date | number | string);
return formatted === 'Invalid Date' ? escapeHtml(value) : formatted;
}
function formatDateValue(value: unknown) {
if (Array.isArray(value)) {
return value.map((item) => tryFormatDate(item)).join(' ~ ');
}
return tryFormatDate(value);
}
function escapeHtml(value: unknown) {
return String(value)
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
}
function 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 record = value;
const displayValue =
getRecordValue(record, 'label') ??
getRecordValue(record, 'name') ??
getRecordValue(record, 'url') ??
getRecordValue(record, 'value') ??
JSON.stringify(value);
return escapeHtml(displayValue);
}
return escapeHtml(value);
}
function createImageHtml(url: string) {
const imgEl = document.createElement('img');
imgEl.setAttribute('src', url);
imgEl.setAttribute('style', 'max-width: 600px; max-height: 300px;');
return imgEl.outerHTML;
}
function 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(String(url)) : '';
})
.filter((s) => isNotEmptyString(s))
.join('<br/>');
}
function 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', String(url));
linkEl.setAttribute('target', '_blank');
linkEl.setAttribute('rel', 'noopener noreferrer');
const fallbackName = String(url).slice(Math.max(0, String(url).lastIndexOf('/') + 1)) || String(url);
const recordName = record ? getRecordValue(record, 'name') : undefined;
linkEl.textContent = recordName ? String(recordName) : fallbackName;
return linkEl.outerHTML;
}
function renderFileListHtml(value: unknown) {
return toValueArray(value)
.map((item) => createFileLinkHtml(item))
.filter((s) => isNotEmptyString(s))
.join('<br/>');
}
function 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(', ');
}
function flattenAreaTree(
list: Array<SystemAreaApi.Area & { children?: SystemAreaApi.Area[] }> = [],
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;
}
function 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);
}
/**
* 按当前表单字段类型按需加载打印所需的关联数据并构建查找映射表
*
* @param formFieldsObj - 已解码的表单字段规则列表
* @returns 打印展示时使用的区域部门用户名称映射
*/
async function loadPrintLookupMaps(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 ? getAreaTree() : Promise.resolve([]),
hasUserSelect ? getSimpleUserList() : Promise.resolve([]),
hasDeptSelect ? getSimpleDeptList() : Promise.resolve([]),
]);
return {
areaMap: flattenAreaTree(areaList as Array<SystemAreaApi.Area>),
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;
}
/**
* 根据表单字段规则和字段值格式化为适合打印展示的 HTML 字符串
*
* @param rule - 表单字段规则包含字段类型选项props 等配置信息
* @param value - 该字段在流程实例中的实际值
* @param lookupMaps - 预加载的查找映射表包含区域部门用户的 idname 映射
* @returns 格式化后的 HTML 字符串纯文本或富文本取决于字段类型
*/
function 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 valueTypeMap = {
bool: 'boolean',
int: 'number',
str: 'string',
} as const;
const rawValueType = String(getRuleProp(rule, 'valueType') ?? '');
const valueType =
(valueTypeMap as Record<string, 'boolean' | 'number' | 'string'>)[rawValueType] ??
'string';
const options = getDictOptions(dictType, valueType);
return mapValuesWithOptions(value, options);
}
case 'Editor':
case 'Tinymce': {
return isEmptyValue(value) ? '' : String(value);
}
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 'UserSelect': {
if (String(getRuleProp(rule, 'returnType')) === 'name') {
return formatPrimitiveValue(value);
}
return mapValueWithLabelMap(value, lookupMaps.userMap);
}
default: {
return formatPrimitiveValue(value);
}
}
}
/** 初始化打印数据映射 */
function initPrintDataMap() {
if (!printData.value) return;
@ -127,16 +446,18 @@ function initPrintDataMap() {
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(
DICT_TYPE.BPM_PROCESS_INSTANCE_STATUS,
printData.value.processInstance.status,
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;
@ -176,7 +497,7 @@ function getPrintTemplateHTML() {
const headTd = document.createElement('td');
headTd.setAttribute('colspan', '2');
headTd.setAttribute('class', 'border border-black p-1.5 text-center');
headTd.innerHTML = '流程记录';
headTd.textContent = '流程记录';
headTr.append(headTd);
processRecordTable.append(headTr);
@ -184,10 +505,10 @@ function getPrintTemplateHTML() {
const tr = document.createElement('tr');
const td1 = document.createElement('td');
td1.setAttribute('class', 'border border-black p-1.5');
td1.innerHTML = item.name;
td1.textContent = item.name;
const td2 = document.createElement('td');
td2.setAttribute('class', 'border border-black p-1.5');
td2.innerHTML = item.description;
td2.textContent = item.description;
tr.append(td1);
tr.append(td2);
processRecordTable.append(tr);