拆分顺序:数据模型 → 接口协议 → 后端实现 → 前端页面 → 前端组件改造 每个任务 = 可独立提交的原子变更(3-5 个文件)
sys_flow_spel_template 表并初始化默认数据涉及文件:
forge-plugin-flow/sql/spel_template.sql — 新增,创建表 + 初始化数据SQL内容:
-- SPEL表达式模板表
CREATE TABLE IF NOT EXISTS `sys_flow_spel_template` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`tenant_id` bigint NOT NULL DEFAULT '1' COMMENT '租户编号',
`template_name` varchar(100) NOT NULL COMMENT '模板名称',
`template_code` varchar(50) NOT NULL COMMENT '模板编码',
`expression` varchar(500) NOT NULL COMMENT 'SPEL表达式',
`description` varchar(200) DEFAULT NULL COMMENT '描述说明',
`category` varchar(50) DEFAULT 'general' COMMENT '分类:general/dept/role/region/custom',
`example_params` varchar(500) DEFAULT NULL COMMENT '示例参数(JSON格式)',
`status` int NOT NULL DEFAULT '1' COMMENT '状态:0-禁用,1-启用',
`sort` int DEFAULT '100' COMMENT '排序',
`remark` varchar(500) DEFAULT NULL COMMENT '备注',
`create_by` bigint DEFAULT NULL COMMENT '创建人ID',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_by` bigint DEFAULT NULL COMMENT '更新人ID',
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
`deleted` int DEFAULT '0' COMMENT '删除标志',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_template_code` (`tenant_id`, `template_code`),
KEY `idx_tenant_id` (`tenant_id`),
KEY `idx_category` (`category`),
KEY `idx_status` (`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='流程SPEL表达式模板';
-- 初始化默认模板数据(迁移自NodePropertiesPanel.vue硬编码模板)
INSERT INTO `sys_flow_spel_template` (`tenant_id`, `template_name`, `template_code`, `expression`, `description`, `category`, `status`, `sort`) VALUES
(1, '发起人', 'initiator', '${initiator}', '流程发起人作为审批人', 'general', 1, 1),
(1, '发起人上级', 'initiatorLeader', '${initiatorLeader}', '流程发起人的直属上级', 'general', 1, 2),
(1, '部门经理', 'deptManager', '${deptManager}', '当前部门的经理', 'general', 1, 3),
(1, 'HR', 'hr', '${hr}', '人力资源部门负责人', 'general', 1, 4),
(1, '根据部门查找负责人', 'findDeptManager', '${flowSpelService.findDeptManager(execution.getVariable("deptId"))}', '查找指定部门的负责人', 'dept', 1, 10),
(1, '根据角色查找用户', 'findUsersByRole', '${flowSpelService.findUsersByRole(execution.getVariable("roleKey"))}', '查找具有指定角色的所有用户', 'role', 1, 20),
(1, '根据行政区划查找负责人', 'findRegionManager', '${flowSpelService.findRegionManager(execution.getVariable("regionCode"))}', '查找指定行政区划的区域负责人', 'region', 1, 30),
(1, '发起人的直属上级', 'getInitiatorLeader', '${flowSpelService.getInitiatorLeader(execution)}', '流程发起人的直属上级作为审批人', 'general', 1, 5),
(1, '根据订单金额动态审批', 'amountDynamic', '${execution.getVariable("amount") > 10000 ? flowSpelService.findUsersByRole("finance_manager") : flowSpelService.findUsersByRole("finance_staff")}', '订单金额大于1万由财务经理审批', 'custom', 1, 50),
(1, '部门+角色组合查询', 'deptAndRole', '${flowSpelService.findUsersByDeptAndRole(execution.getVariable("deptId"), "dept_manager")}', '查找指定部门的部门经理', 'dept', 1, 15)
ON DUPLICATE KEY UPDATE `template_name` = VALUES(`template_name`);
FlowSpelTemplate 实体类和 Mapper涉及文件:
forge-plugin-flow/src/main/java/.../entity/FlowSpelTemplate.java — 新增forge-plugin-flow/src/main/java/.../mapper/FlowSpelTemplateMapper.java — 新增forge-plugin-flow/src/main/resources/mapper/FlowSpelTemplateMapper.xml — 新增Entity代码 (FlowSpelTemplate.java):
package com.mdframe.forge.starter.flow.entity;
import com.baomidou.mybatisplus.annotation.*;
import lombok.Data;
import java.io.Serializable;
import java.time.LocalDateTime;
@Data
@TableName("sys_flow_spel_template")
public class FlowSpelTemplate implements Serializable {
private static final long serialVersionUID = 1L;
@TableId(type = IdType.AUTO)
private Long id;
private Long tenantId;
private String templateName;
private String templateCode;
private String expression;
private String description;
private String category;
private String exampleParams;
private Integer status;
private Integer sort;
private String remark;
@TableField(fill = FieldFill.INSERT)
private Long createBy;
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;
@TableField(fill = FieldFill.INSERT_UPDATE)
private Long updateBy;
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updateTime;
@TableLogic
private Integer deleted;
}
FlowSpelTemplateMapper.java):package com.mdframe.forge.starter.flow.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.mdframe.forge.starter.flow.entity.FlowSpelTemplate;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface FlowSpelTemplateMapper extends BaseMapper<FlowSpelTemplate> {
}
FlowSpelTemplateMapper.xml):<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.mdframe.forge.starter.flow.mapper.FlowSpelTemplateMapper">
<resultMap id="BaseResultMap" type="com.mdframe.forge.starter.flow.entity.FlowSpelTemplate">
<id column="id" property="id"/>
<result column="tenant_id" property="tenantId"/>
<result column="template_name" property="templateName"/>
<result column="template_code" property="templateCode"/>
<result column="expression" property="expression"/>
<result column="description" property="description"/>
<result column="category" property="category"/>
<result column="example_params" property="exampleParams"/>
<result column="status" property="status"/>
<result column="sort" property="sort"/>
<result column="remark" property="remark"/>
<result column="create_by" property="createBy"/>
<result column="create_time" property="createTime"/>
<result column="update_by" property="updateBy"/>
<result column="update_time" property="updateTime"/>
<result column="deleted" property="deleted"/>
</resultMap>
<!-- 查询启用状态的模板列表 -->
<select id="selectEnabledList" resultMap="BaseResultMap">
SELECT * FROM sys_flow_spel_template
WHERE tenant_id = #{tenantId}
AND status = 1
AND deleted = 0
ORDER BY sort ASC, id ASC
</select>
</mapper>
涉及文件:
forge-plugin-flow/src/main/java/.../service/FlowSpelTemplateService.java — 新增forge-plugin-flow/src/main/java/.../service/impl/FlowSpelTemplateServiceImpl.java — 新增forge-flow-server/src/main/java/.../controller/FlowSpelTemplateController.java — 新增Service接口:
package com.mdframe.forge.starter.flow.service;
import com.baomidou.mybatisplus.extension.service.IService;
import com.mdframe.forge.starter.flow.entity.FlowSpelTemplate;
import java.util.List;
public interface FlowSpelTemplateService extends IService<FlowSpelTemplate> {
List<FlowSpelTemplate> getEnabledList(Long tenantId);
}
package com.mdframe.forge.starter.flow.service.impl;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.mdframe.forge.starter.flow.entity.FlowSpelTemplate;
import com.mdframe.forge.starter.flow.mapper.FlowSpelTemplateMapper;
import com.mdframe.forge.starter.flow.service.FlowSpelTemplateService;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
public class FlowSpelTemplateServiceImpl extends ServiceImpl<FlowSpelTemplateMapper, FlowSpelTemplate> implements FlowSpelTemplateService {
@Override
public List<FlowSpelTemplate> getEnabledList(Long tenantId) {
return baseMapper.selectEnabledList(tenantId);
}
}
FlowSpelTemplateController.java):package com.mdframe.forge.flow.controller;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.mdframe.forge.starter.flow.entity.FlowSpelTemplate;
import com.mdframe.forge.starter.flow.service.FlowSpelTemplateService;
import com.mdframe.forge.starter.common.core.domain.RespInfo;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/flow/spelTemplate")
@RequiredArgsConstructor
public class FlowSpelTemplateController {
private final FlowSpelTemplateService spelTemplateService;
// 分页查询
@GetMapping("/page")
public RespInfo<?> page(
@RequestParam(defaultValue = "1") Integer pageNum,
@RequestParam(defaultValue = "10") Integer pageSize,
@RequestParam(required = false) String templateName,
@RequestParam(required = false) String category,
@RequestParam(required = false) Integer status) {
Page<FlowSpelTemplate> page = new Page<>(pageNum, pageSize);
LambdaQueryWrapper<FlowSpelTemplate> wrapper = new LambdaQueryWrapper<>();
wrapper.like(templateName != null, FlowSpelTemplate::getTemplateName, templateName);
wrapper.eq(category != null, FlowSpelTemplate::getCategory, category);
wrapper.eq(status != null, FlowSpelTemplate::getStatus, status);
wrapper.orderByAsc(FlowSpelTemplate::getSort);
return RespInfo.success(spelTemplateService.page(page, wrapper));
}
// 启用状态列表(节点配置下拉用)
@GetMapping("/list")
public RespInfo<?> list() {
Long tenantId = 1L; // 从上下文获取当前租户ID
List<FlowSpelTemplate> list = spelTemplateService.getEnabledList(tenantId);
return RespInfo.success(list);
}
// 详情
@GetMapping("/{id}")
public RespInfo<?> getById(@PathVariable Long id) {
return RespInfo.success(spelTemplateService.getById(id));
}
// 新增
@PostMapping
public RespInfo<?> save(@RequestBody FlowSpelTemplate entity) {
spelTemplateService.save(entity);
return RespInfo.success();
}
// 修改
@PutMapping
public RespInfo<?> update(@RequestBody FlowSpelTemplate entity) {
spelTemplateService.updateById(entity);
return RespInfo.success();
}
// 删除
@DeleteMapping("/{id}")
public RespInfo<?> delete(@PathVariable Long id) {
spelTemplateService.removeById(id);
return RespInfo.success();
}
}
spelTemplate.vue 管理页面涉及文件:
forge-admin-ui/src/views/flow/spelTemplate.vue — 新增页面结构: 使用 AiCrudPage 组件实现 CRUD
<template>
<AiCrudPage
ref="crudRef"
:api-config="{
list: 'get@/api/flow/spelTemplate/page',
detail: 'get@/api/flow/spelTemplate/{id}',
add: 'post@/api/flow/spelTemplate',
update: 'put@/api/flow/spelTemplate',
delete: 'delete@/api/flow/spelTemplate/{id}'
}"
:search-schema="searchSchema"
:columns="tableColumns"
:edit-schema="editSchema"
row-key="id"
:load-detail-on-edit="true"
/>
</template>
<script setup>
import { AiCrudPage } from '@/components/ai-form'
import { h, ref } from 'vue'
import { NTag } from 'naive-ui'
defineOptions({ name: 'FlowSpelTemplate' })
const categoryOptions = [
{ label: '通用', value: 'general' },
{ label: '部门', value: 'dept' },
{ label: '角色', value: 'role' },
{ label: '行政区划', value: 'region' },
{ label: '自定义', value: 'custom' },
]
const statusOptions = [
{ label: '启用', value: 1 },
{ label: '禁用', value: 0 },
]
const searchSchema = [
{ field: 'templateName', label: '模板名称', type: 'input', props: { placeholder: '请输入模板名称' } },
{ field: 'category', label: '分类', type: 'select', props: { options: categoryOptions, clearable: true } },
{ field: 'status', label: '状态', type: 'select', props: { options: statusOptions, clearable: true } },
]
const tableColumns = [
{ prop: 'templateName', label: '模板名称', width: 150 },
{ prop: 'templateCode', label: '模板编码', width: 120 },
{ prop: 'expression', label: '表达式', ellipsis: { tooltip: true } },
{ prop: 'category', label: '分类', width: 100, render: (row) => {
const item = categoryOptions.find(o => o.value === row.category)
return item ? item.label : row.category
}},
{ prop: 'status', label: '状态', width: 80, render: (row) => h(NTag, { type: row.status === 1 ? 'success' : 'warning' }, () => row.status === 1 ? '启用' : '禁用') },
{ prop: 'sort', label: '排序', width: 80 },
{ prop: 'createTime', label: '创建时间', width: 160 },
]
const editSchema = [
{ field: 'templateName', label: '模板名称', type: 'input', rules: [{ required: true, message: '请输入模板名称' }] },
{ field: 'templateCode', label: '模板编码', type: 'input', rules: [{ required: true, message: '请输入模板编码' }] },
{ field: 'expression', label: 'SPEL表达式', type: 'textarea', rules: [{ required: true, message: '请输入表达式' }], props: { rows: 3, placeholder: '${flowSpelService.findDeptManager(...)}' } },
{ field: 'description', label: '描述说明', type: 'input', props: { placeholder: '简要描述模板用途' } },
{ field: 'category', label: '分类', type: 'select', props: { options: categoryOptions } },
{ field: 'exampleParams', label: '示例参数', type: 'textarea', props: { rows: 2, placeholder: 'JSON格式,如 {"deptId": "123"}' } },
{ field: 'status', label: '状态', type: 'switch', defaultValue: 1, props: { checkedValue: 1, uncheckedValue: 0 } },
{ field: 'sort', label: '排序', type: 'input-number', defaultValue: 100, props: { min: 0, max: 999 } },
{ field: 'remark', label: '备注', type: 'textarea', props: { rows: 2 } },
]
</script>
n-tabs 分隔 + 底部添加"保存配置"按钮涉及文件:
forge-admin-ui/src/components/bpmn/NodePropertiesPanel.vue — 重构关键改动点:
n-collapse 替换为 n-tabs/api/flow/spelTemplate/list 加载 SPEL 模板Tab结构设计:
<template>
<div class="node-properties-panel">
<!-- 用户/角色选择弹窗保持不变 -->
<!-- Tab分隔 -->
<n-tabs v-model:value="activeTab" type="line" size="small">
<!-- 基础属性Tab -->
<n-tab-pane name="basic" tab="基础属性">
<n-form :model="properties" label-placement="top" size="small">
<n-form-item label="节点ID">
<n-input v-model:value="properties.id" @input="markDirty" />
</n-form-item>
<n-form-item label="节点名称">
<n-input v-model:value="properties.name" @input="markDirty" />
</n-form-item>
<n-form-item label="节点描述">
<n-input v-model:value="properties.documentation" type="textarea" @input="markDirty" />
</n-form-item>
</n-form>
</n-tab-pane>
<!-- 审批设置Tab(仅用户任务) -->
<n-tab-pane v-if="elementType === 'bpmn:UserTask'" name="approval" tab="审批设置">
<!-- 原审批设置内容迁移到此 -->
</n-tab-pane>
<!-- 会签配置Tab -->
<n-tab-pane v-if="elementType === 'bpmn:UserTask'" name="multiInstance" tab="会签配置">
<!-- 原会签配置内容迁移到此 -->
</n-tab-pane>
<!-- 监听器Tab -->
<n-tab-pane v-if="showExecutionListener" name="listener" tab="监听器">
<!-- 原监听器内容迁移到此 -->
</n-tab-pane>
</n-tabs>
<!-- 底部固定按钮区 -->
<div class="panel-footer">
<n-alert v-if="isDirty" type="warning" size="small">
配置已修改,请点击保存按钮生效
</n-alert>
<n-button type="primary" block :loading="saving" @click="handleSaveConfig">
<template #icon>
<i class="i-material-symbols:save" />
</template>
保存配置
</n-button>
</div>
</div>
</template>
<script setup>
// 新增状态
const activeTab = ref('basic')
const isDirty = ref(false)
const saving = ref(false)
const spelTemplatesFromApi = ref([])
// 标记为未保存
function markDirty() {
isDirty.value = true
}
// 从API加载SPEL模板
async function loadSpelTemplates() {
try {
const res = await request.get('/api/flow/spelTemplate/list')
if (res.code === 200) {
spelTemplatesFromApi.value = (res.data || []).map(t => ({
label: t.templateName,
value: t.expression,
description: t.description
}))
}
} catch (e) {
console.error('加载SPEL模板失败', e)
}
}
// 手动保存配置
async function handleSaveConfig() {
saving.value = true
try {
// 执行所有必要的update方法
updateProperty('id')
updateProperty('name')
updateProperty('documentation')
if (elementType.value === 'bpmn:UserTask') {
updateUserTaskAssignee()
updateMultiInstance()
updateTaskListeners()
}
// 触发父组件更新
emit('update')
isDirty.value = false
window.$message?.success('配置已保存')
} finally {
saving.value = false
}
}
// 组件挂载时加载SPEL模板
onMounted(() => {
loadSpelTemplates()
})
</script>
<style scoped>
.panel-footer {
padding: 12px 16px;
border-top: 1px solid var(--border);
background: var(--surface);
position: sticky;
bottom: 0;
}
</style>
涉及文件:
forge-admin-ui/src/components/bpmn/NodePropertiesPanel.vue — 修改关键改动:
spelTemplates 硬编码数组(第705-742行)spelTemplatesFromApi refonMounted 中调用 /api/flow/spelTemplate/list涉及文件:
forge-admin-ui/src/components/bpmn/FlowModeler.vue — 修改关键改动:
max-height: 80vhword-wrap<!-- XML预览弹窗优化 -->
<n-modal
v-model:show="showPreviewModal"
preset="card"
title="BPMN XML 预览"
style="width: 900px; max-height: 80vh"
>
<div class="xml-preview-container">
<n-code
:code="previewXml"
language="xml"
:show-line-numbers="true"
:word-wrap="true"
/>
</div>
<template #footer>
<n-space justify="end">
<n-button @click="handleCopyXml">
<template #icon>
<i class="i-material-symbols:content-copy" />
</template>
复制XML
</n-button>
<n-button type="primary" @click="handleDownloadXmlFromPreview">
<template #icon>
<i class="i-material-symbols:download" />
</template>
下载XML
</n-button>
<n-button @click="showPreviewModal = false">
关闭
</n-button>
</n-space>
</template>
</n-modal>
<script setup>
// 新增复制功能
function handleCopyXml() {
navigator.clipboard.writeText(previewXml.value)
window.$message?.success('XML已复制到剪贴板')
}
// 从预览弹窗下载
function handleDownloadXmlFromPreview() {
downloadFile(previewXml.value, 'diagram.bpmn', 'application/xml')
window.$message?.success('下载成功')
}
</script>
<style scoped>
.xml-preview-container {
max-height: 60vh;
overflow-y: auto;
overflow-x: auto;
background: #1e1e1e;
border-radius: 8px;
padding: 16px;
}
:deep(.n-code pre) {
white-space: pre-wrap;
word-break: break-all;
}
:deep(.n-code) {
background: transparent;
}
</style>
forge-plugin-flow/sql/spel_template_menu.sql — 新增-- SPEL模板管理菜单(置于流程配置目录下)
-- 假设流程配置目录ID为 @flow_admin_id
INSERT INTO sys_resource (tenant_id, resource_name, parent_id, resource_type, sort, path, component, is_external, is_public, menu_status, visible, icon, keep_alive, always_show, remark, create_time, update_time)
VALUES (0, 'SPEL模板管理', @flow_admin_id, 2, 5, '/flow/admin/spelTemplate', '/flow/spelTemplate', 0, 0, 1, 1, 'mdi:code-braces', 0, 0, 'SPEL表达式模板管理', NOW(), NOW());
SET @spel_template_menu_id = LAST_INSERT_ID();
-- 按钮权限
INSERT INTO sys_resource (tenant_id, resource_name, parent_id, resource_type, sort, perms, is_external, is_public, menu_status, visible, remark, create_time, update_time)
VALUES
(0, '新增模板', @spel_template_menu_id, 3, 1, 'flow:spelTemplate:create', 0, 0, 1, 1, '新增SPEL模板', NOW(), NOW()),
(0, '编辑模板', @spel_template_menu_id, 3, 2, 'flow:spelTemplate:update', 0, 0, 1, 1, '编辑SPEL模板', NOW(), NOW()),
(0, '删除模板', @spel_template_menu_id, 3, 3, 'flow:spelTemplate:delete', 0, 0, 1, 1, '删除SPEL模板', NOW(), NOW());
mvn clean installpnpm build