feat: [bpm][antd] 流程打印自定义模板

This commit is contained in:
jason
2025-11-15 21:52:09 +08:00
parent a3356a0a5e
commit ec0518f36a
5 changed files with 349 additions and 47 deletions

View File

@@ -0,0 +1,127 @@
<script setup lang="ts">
import type { MentionItem } from '../modules/tinymce-plugin';
import { computed, onBeforeUnmount, ref, shallowRef } from 'vue';
import { useVbenModal } from '@vben/common-ui';
// @ts-ignore - tinymce vue 声明文件按项目依赖提供
import Editor from '@tinymce/tinymce-vue';
import { setupTinyPlugins } from './tinymce-plugin';
const props = withDefaults(
defineProps<{
formFields?: Array<{ field: string; title: string }>;
}>(),
{
formFields: () => [],
},
);
/** TinyMCE 自托管https://www.jianshu.com/p/59a9c3802443 */
const tinymceScriptSrc = `${import.meta.env.VITE_BASE}tinymce/tinymce.min.js`;
const [Modal, modalApi] = useVbenModal({
closable: true,
footer: false,
title: '自定义模板',
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
return;
}
modalApi.lock();
try {
const { template } = modalApi.getData<{
template: string;
}>();
if (template !== undefined) {
valueHtml.value = template;
}
} finally {
modalApi.unlock();
}
},
});
const handleConfirm = () => {
/** 通过 setData 传递确认的数据,在父组件的 onConfirm 中获取 */
modalApi.setData({ confirmedTemplate: valueHtml.value as string });
modalApi.onConfirm();
modalApi.close();
};
// 提供给 @ 自动补全的字段(默认 + 表单字段)
const mentionList = computed<MentionItem[]>(() => {
const base: MentionItem[] = [
{ id: 'startUser', name: '发起人' },
{ id: 'startUserDept', name: '发起人部门' },
{ id: 'processName', name: '流程名称' },
{ id: 'processNum', name: '流程编号' },
{ id: 'startTime', name: '发起时间' },
{ id: 'endTime', name: '结束时间' },
{ id: 'processStatus', name: '流程状态' },
{ id: 'printUser', name: '打印人' },
{ id: 'printTime', name: '打印时间' },
];
const extras: MentionItem[] = (props.formFields || []).map((it: any) => ({
id: it.field,
name: `[表单]${it.title}`,
}));
return [...base, ...extras];
});
// 编辑器
const valueHtml = ref<string>('');
const editorRef = shallowRef<any>();
const tinyInit = {
height: 400,
width: 'auto',
menubar: false,
plugins: 'link importcss table code preview autoresize lists ',
toolbar:
'undo redo | styles fontsize | bold italic underline | alignleft aligncenter alignright | link table | processrecord code preview',
language: 'zh_CN',
branding: false,
statusbar: true,
content_style:
'body { font-family:Helvetica,Arial,sans-serif; font-size:16px }',
setup(editor: any) {
editorRef.value = editor;
// 在编辑器 setup 时注册自定义插件
setupTinyPlugins(editor, () => mentionList.value);
},
};
onBeforeUnmount(() => {
if (editorRef.value) {
editorRef.value.destroy?.();
}
});
</script>
<template>
<Modal class="w-3/4">
<div class="mb-3">
<a-alert
message="输入 @ 可选择插入流程选项和表单选项"
type="info"
show-icon
/>
</div>
<Editor
v-model="valueHtml"
:init="tinyInit"
:tinymce-script-src="tinymceScriptSrc"
license-key="gpl"
/>
<template #footer>
<div class="flex justify-end gap-2">
<a-button @click="modalApi.onCancel()"> </a-button>
<a-button type="primary" @click="handleConfirm"> </a-button>
</div>
</template>
</Modal>
</template>

View File

@@ -1,6 +1,7 @@
<script setup lang="ts">
import { computed, provide, ref, watch } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import {
BpmAutoApproveType,
BpmModelFormType,
@@ -9,6 +10,7 @@ import {
import { IconifyIcon } from '@vben/icons';
import {
Button,
Checkbox,
Col,
Form,
@@ -32,6 +34,8 @@ import {
parseFormFields,
} from '#/views/bpm/components/simple-process-design';
import PrintTemplate from './custom-print-template.vue';
const modelData = defineModel<any>();
/** 自定义 ID 流程编码 */
@@ -147,9 +151,9 @@ function handleTaskAfterTriggerEnableChange(val: boolean | number | string) {
}
/** 表单字段 */
const formField = ref<Array<{ field: string; title: string }>>([]);
const formFields = ref<Array<{ field: string; title: string }>>([]);
const formFieldOptions4Title = computed(() => {
const cloneFormField = formField.value.map((item) => {
const cloneFormField = formFields.value.map((item) => {
return {
label: item.title,
value: item.field,
@@ -171,7 +175,7 @@ const formFieldOptions4Title = computed(() => {
return cloneFormField;
});
const formFieldOptions4Summary = computed(() => {
return formField.value.map((item) => {
return formFields.value.map((item) => {
return {
label: item.title,
value: item.field,
@@ -192,6 +196,12 @@ function initData() {
length: 5,
};
}
if (!modelData.value.printTemplateSetting) {
modelData.value.printTemplateSetting = {
enable: false,
template: '',
};
}
if (!modelData.value.autoApprovalType) {
modelData.value.autoApprovalType = BpmAutoApproveType.NONE;
}
@@ -237,9 +247,9 @@ watch(
parseFormFields(JSON.parse(fieldStr), result);
});
}
formField.value = result;
formFields.value = result;
} else {
formField.value = [];
formFields.value = [];
unParsedFormFields.value = [];
}
},
@@ -252,6 +262,86 @@ async function validate() {
await formRef.value?.validate();
}
/** 自定义打印模板模态框 */
const [PrintTemplateModal, printTemplateModalApi] = useVbenModal({
connectedComponent: PrintTemplate,
destroyOnClose: true,
onConfirm() {
/** 从 modalApi 获取确认的数据 */
const { confirmedTemplate } = printTemplateModalApi.getData<{
confirmedTemplate: string;
}>();
if (confirmedTemplate !== undefined) {
modelData.value.printTemplateSetting.template = confirmedTemplate;
}
},
});
/** 弹出自定义打印模板弹窗 */
const openPrintTemplateModal = () => {
printTemplateModalApi
.setData({ template: modelData.value.printTemplateSetting.template })
.open();
};
/** 默认的打印模板, 目前自定义模板没有引入自定义样式。 看后续是否需要 */
const defaultTemplate = `<p style="text-align: center;font-size: 1.25rem;"><strong><span data-w-e-type="mention" data-value="流程名称" data-info="%7B%22id%22%3A%22processName%22%7D">@流程名称</span></strong></p>
<p style="text-align: right;">打印人员:<span data-w-e-type="mention" data-info="%7B%22id%22%3A%22printUser%22%7D">@打印人</span></p>
<p style="text-align: left;">流程编号:<span data-w-e-type="mention" data-value="流程编号" data-info="%7B%22id%22%3A%22processNum%22%7D">@流程编号</span></p>
<p>&nbsp;</p>
<table style="width: 100%; height: 72.2159px;">
<tbody>
<tr style="height: 36.108px;">
<td style="width: 21.7532%; border: 1px solid;" colspan="1" rowspan="1" width="auto">发起人</td>
<td style="width: 30.5551%; border: 1px solid;" colspan="1" rowspan="1" width="auto"><span data-w-e-type="mention" data-value="发起人" data-info="%7B%22id%22%3A%22startUser%22%7D">@发起人</span></td>
<td style="width: 21.7532%; border: 1px solid;" colspan="1" rowspan="1" width="auto">发起时间</td>
<td style="width: 26.0284%; border: 1px solid;" colspan="1" rowspan="1" width="auto"><span data-w-e-type="mention" data-value="发起时间" data-info="%7B%22id%22%3A%22startTime%22%7D">@发起时间</span></td>
</tr>
<tr style="height: 36.108px;">
<td style="width: 21.7532%; border: 1px solid;" colspan="1" rowspan="1" width="auto">所属部门</td>
<td style="width: 30.5551%; border: 1px solid;" 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 style="width: 21.7532%; border: 1px solid;" colspan="1" rowspan="1" width="auto">流程状态</td>
<td style="width: 26.0284%; border: 1px solid;" colspan="1" rowspan="1" width="auto"><span data-w-e-type="mention" data-value="流程状态" data-info="%7B%22id%22%3A%22processStatus%22%7D">@流程状态</span></td>
</tr>
</tbody>
</table>
<p>&nbsp;</p>
<div contenteditable="false" data-w-e-type="process-record" data-w-e-is-void="">
<table class="process-record-table" style="width: 100%; border-collapse: collapse; border: 1px solid;">
<tr>
<td style="width: 100%; border: 1px solid; text-align: center;" colspan="2">流程记录</td>
</tr>
<tr>
<td style="width: 25%; border: 1px solid;">节点</td>
<td style="width: 75%; border: 1px solid;">操作</td>
</tr>
</table>
</div>
<p>&nbsp;</p>`;
const handlePrintTemplateEnableChange = (checked: any) => {
const val = !!checked;
if (val && !modelData.value.printTemplateSetting.template) {
modelData.value.printTemplateSetting.template = defaultTemplate;
}
};
// 自定义打印模板开关
const printTemplateEnable = computed<boolean>({
get() {
return !!modelData.value?.printTemplateSetting?.enable;
},
set(val: boolean) {
if (!modelData.value.printTemplateSetting) {
modelData.value.printTemplateSetting = {
enable: false,
template: '',
};
}
modelData.value.printTemplateSetting.enable = !!val;
},
});
defineExpose({ initData, validate });
</script>
<template>
@@ -515,6 +605,27 @@ defineExpose({ initData, validate });
</Col>
</Row>
</FormItem>
<!-- TODO @jason这里有个 自定义打印模板 -->
<FormItem class="mb-5" label="自定义打印模板">
<div class="flex w-full flex-col">
<div class="flex items-center">
<Switch
v-model:checked="printTemplateEnable"
@change="handlePrintTemplateEnableChange"
/>
<Button
v-if="printTemplateEnable"
class="ml-2 flex items-center"
type="link"
@click="openPrintTemplateModal"
>
<template #icon>
<IconifyIcon icon="lucide:pencil" />
</template>
编辑模板
</Button>
</div>
</div>
</FormItem>
<PrintTemplateModal :form-fields="formFields" />
</Form>
</template>

View File

@@ -0,0 +1,78 @@
/** TinyMCE 自定义功能:
* - processrecord 按钮:插入流程记录占位元素
* - @ 自动补全:插入 mention 占位元素
*/
// @ts-ignore TinyMCE 全局或通过打包器提供
import type { Editor } from 'tinymce';
export interface MentionItem {
id: string;
name: string;
}
/** 在编辑器 setup 回调中注册流程记录按钮和 @ 自动补全 */
export function setupTinyPlugins(
editor: Editor,
getMentionList: () => MentionItem[],
) {
// 按钮:流程记录
editor.ui.registry.addButton('processrecord', {
text: '流程记录',
tooltip: '插入流程记录占位',
onAction: () => {
// 流程记录占位显示, 仅用于显示。process-print.vue 组件中会替换掉
editor.insertContent(
[
'<div data-w-e-type="process-record" data-w-e-is-void contenteditable="false">',
'<table class="process-record-table" style="width: 100%; border-collapse: collapse; border: 1px solid;">',
'<tr><td style="width: 100%; border: 1px solid; text-align: center;" colspan="2">流程记录</td></tr>',
'<tr>',
'<td style="width: 25%; border: 1px solid;">节点</td>',
'<td style="width: 75%; border: 1px solid;">操作</td>',
'</tr>',
'</table>',
'</div>',
].join(''),
);
},
});
// @ 自动补全
editor.ui.registry.addAutocompleter('bpmMention', {
trigger: '@',
minChars: 0,
columns: 1,
fetch: (
pattern: string,
_maxResults: number,
_fetchOptions: Record<string, any>,
) => {
const list = getMentionList();
const keyword = (pattern || '').toLowerCase().trim();
const data = list
.filter((i) => i.name.toLowerCase().includes(keyword))
.map((i) => ({
value: i.id,
text: i.name,
}));
return Promise.resolve(data);
},
onAction: (
autocompleteApi: any,
rng: Range,
value: string,
_meta: Record<string, any>,
) => {
const list = getMentionList();
const item = list.find((i) => i.id === value);
const name = item ? item.name : value;
const info = encodeURIComponent(JSON.stringify({ id: value }));
editor.selection.setRng(rng);
editor.insertContent(
`<span data-w-e-type="mention" data-info="${info}">@${name}</span>`,
);
autocompleteApi.hide();
},
});
}

View File

@@ -34,7 +34,7 @@ import { registerComponent } from '#/utils';
import ProcessInstanceBpmnViewer from './modules/bpm-viewer.vue';
import ProcessInstanceOperationButton from './modules/operation-button.vue';
import ProcessssPrint from './modules/processs-print.vue';
import ProcessssPrint from './modules/process-print.vue';
import ProcessInstanceSimpleViewer from './modules/simple-bpm-viewer.vue';
import BpmProcessInstanceTaskList from './modules/task-list.vue';
import ProcessInstanceTimeline from './modules/time-line.vue';

View File

@@ -143,7 +143,7 @@ function initPrintDataMap() {
printDataMap.value.printTime = printTime.value;
}
/** 获取打印模板 HTML TODO 需求实现配置打印模板) */
/** 获取打印模板 HTML */
function getPrintTemplateHTML() {
if (!printData.value?.printTemplateHtml) return '';
@@ -153,16 +153,6 @@ function getPrintTemplateHTML() {
'text/html',
);
// table border
const tables = doc.querySelectorAll('table');
tables.forEach((item) => {
item.setAttribute('border', '1');
item.setAttribute(
'style',
`${item.getAttribute('style') || ''}border-collapse:collapse;`,
);
});
// mentions
const mentions = doc.querySelectorAll('[data-w-e-type="mention"]');
mentions.forEach((item) => {
@@ -181,26 +171,23 @@ function getPrintTemplateHTML() {
if (processRecords.length > 0) {
// html
processRecordTable.setAttribute('border', '1');
processRecordTable.setAttribute(
'style',
'width:100%;border-collapse:collapse;',
);
processRecordTable.setAttribute('class', 'w-full border-collapse');
const headTr = document.createElement('tr');
const headTd = document.createElement('td');
headTd.setAttribute('colspan', '2');
headTd.setAttribute('width', 'auto');
headTd.setAttribute('style', 'text-align: center;');
headTd.innerHTML = '流程节点';
headTd.setAttribute('class', 'border border-black p-1.5 text-center');
headTd.innerHTML = '流程记录';
headTr.append(headTd);
processRecordTable.append(headTr);
printData.value?.tasks.forEach((item) => {
const tr = document.createElement('tr');
const td1 = document.createElement('td');
td1.setAttribute('class', 'border border-black p-1.5');
td1.innerHTML = item.name;
const td2 = document.createElement('td');
td2.setAttribute('class', 'border border-black p-1.5');
td2.innerHTML = item.description;
tr.append(td1);
tr.append(td2);
@@ -229,35 +216,34 @@ function getPrintTemplateHTML() {
<h2 class="mb-3 text-center text-xl font-bold">
{{ printData.processInstance.name }}
</h2>
<div class="mb-2 text-right text-sm">
{{ `打印人员: ${userName}` }}
</div>
<div class="mb-2 flex justify-between text-sm">
<div>
{{ `流程编号: ${printData.processInstance.id}` }}
</div>
<div>{{ `打印时间: ${printTime}` }}</div>
<div>
{{ `打印人员: ${userName}` }}
</div>
</div>
<table class="mt-3 w-full border-collapse border border-gray-400">
<table class="mt-3 w-full border-collapse">
<tbody>
<tr>
<td class="w-1/4 border border-gray-400 p-1.5">发起人</td>
<td class="w-1/4 border border-gray-400 p-1.5">
<td class="w-1/4 border border-black p-1.5">发起人</td>
<td class="w-1/4 border border-black p-1.5">
{{ printData.processInstance.startUser?.nickname }}
</td>
<td class="w-1/4 border border-gray-400 p-1.5">发起时间</td>
<td class="w-1/4 border border-gray-400 p-1.5">
<!-- TODO @jason这里会告警呢 -->
<td class="w-1/4 border border-black p-1.5">发起时间</td>
<td class="w-1/4 border border-black p-1.5">
<!-- TODO @jason这里会告警呢 TODO @芋艿 我这边不会有警告呀 -->
{{ formatDate(printData.processInstance.startTime) }}
</td>
</tr>
<tr>
<td class="w-1/4 border border-gray-400 p-1.5">所属部门</td>
<td class="w-1/4 border border-gray-400 p-1.5">
<td class="w-1/4 border border-black p-1.5">所属部门</td>
<td class="w-1/4 border border-black p-1.5">
{{ printData.processInstance.startUser?.deptName }}
</td>
<td class="w-1/4 border border-gray-400 p-1.5">流程状态</td>
<td class="w-1/4 border border-gray-400 p-1.5">
<td class="w-1/4 border border-black p-1.5">流程状态</td>
<td class="w-1/4 border border-black p-1.5">
{{
getDictLabel(
DICT_TYPE.BPM_PROCESS_INSTANCE_STATUS,
@@ -268,33 +254,33 @@ function getPrintTemplateHTML() {
</tr>
<tr>
<td
class="w-full border border-gray-400 p-1.5 text-center"
class="w-full border border-black p-1.5 text-center"
colspan="4"
>
<h4>表单内容</h4>
</td>
</tr>
<tr v-for="item in formFields" :key="item.id">
<td class="w-1/5 border border-gray-400 p-1.5">
<td class="w-1/5 border border-black p-1.5">
{{ item.name }}
</td>
<td class="w-4/5 border border-gray-400 p-1.5" colspan="3">
<td class="w-4/5 border border-black p-1.5" colspan="3">
<div v-html="item.html"></div>
</td>
</tr>
<tr>
<td
class="w-full border border-gray-400 p-1.5 text-center"
class="w-full border border-black p-1.5 text-center"
colspan="4"
>
<h4>流程节点</h4>
<h4>流程记录</h4>
</td>
</tr>
<tr v-for="item in printData.tasks" :key="item.id">
<td class="w-1/5 border border-gray-400 p-1.5">
<td class="w-1/5 border border-black p-1.5">
{{ item.name }}
</td>
<td class="w-4/5 border border-gray-400 p-1.5" colspan="3">
<td class="w-4/5 border border-black p-1.5" colspan="3">
{{ item.description }}
<div v-if="item.signPicUrl && item.signPicUrl.length > 0">
<img class="h-10 w-[90px]" :src="item.signPicUrl" alt="" />