首先我们需要明白的是动态表单主要分为两类:内置、外置。在这分别介绍一下内置外置表单。
一、内置表单中的动态表单 (Flowable Forms)
Flowable 自带的表单引擎(Flowable Forms)支持一定程度的动态能力。因为是内置方案,它的实现相对简单,但不那么灵活。
实现机制
内置表单的动态性主要通过在表单的 JSON 定义中使用 表达式(Expressions) 来实现。你可以在表单设计器中为组件的 visibility (可见性) 或 disabled (禁用) 等属性设置条件表达式。
语法: 表达式通常使用 {{…}} 包裹。
原理: Flowable Form 的前端渲染引擎会实时监听表单中各个字段值的变化。当某个值改变时,它会重新计算所有依赖该值的表达式,并根据计算结果(true 或 false)来更新对应组件的显示状态。
举例:请假类型联动
场景:创建一个请假表单,当“请假类型”选择“其他”时,才显示“具体事由”这个文本框。
创建表单字段:
一个下拉框,ID 为 type,选项包含“年假”、“病假”、“其他”。
一个文本输入框,ID 为 otherReason。
但是一般很少用内置的表单!!!!所以在这不详细讲了。
二、外置表单中的动态表单 (External Forms)
二、外置表单中的动态表单 (External Forms)
这是实现动态表单最主流、最强大、最灵活的方式。
实现机制
在外置表单模式下,Flowable 引擎完全不关心表单长什么样、有什么动态效果。所有的动态逻辑都由你的前端应用(Vue/React/Angular 等)全权负责。
你拥有前端框架提供的所有“武器”来实现任何你能想到的动态效果:
条件渲染: Vue 的 v-if/v-show,React 的 {condition && }。
数据绑定: Vue 的 v-model,React 的 state + onChange。
侦听器 (Watchers): 监听某个数据的变化,然后执行复杂的逻辑,比如去后端请求新的数据来填充另一个下拉框。
计算属性 (Computed Properties): 根据一个或多个数据派生出新的状态。
事件处理: 响应用户的各种点击、输入事件。
在这我们同个一个举例来详细了解一下外置表单。
场景设定
流程:员工提交请假申请 -> 直属领导审批 -> 流程结束。
角色:员工(employee),领导(manager)。
技术栈:
后端:Spring Boot
流程引擎:Flowable
前端:Vue + Vue Router + Axios
第 1 步:设计 BPMN 流程并定义 formKey
这是所有工作的基础。我们创建一个名为 leave-process.bpmn20.xml 的文件。
核心节点与 formKey 设置:
申请请假 (User Task):
ID: applyTask
负责人: 由流程发起人自己处理,所以用 ${initiator}
formKey: leave-apply-form (这是我们给前端Vue组件起的名字)
领导审批 (User Task):
ID: approvalTask
负责人: manager (为简化,我们写死;实际项目中会是动态的)
formKey: leave-approval-form (这是审批页面的Vue组件名)
BPMN 核心 XML 代码:
<?xml version="1.0" encoding="UTF-8"?>
<definitions xmlns="http://www.omg.org/spec/BPMN/2.0/MODEL"
xmlns:flowable="http://flowable.org/bpmn"
targetNamespace="http://flowable.org/examples">
<process id="leaveProcess" name="请假流程">
<startEvent id="startEvent" flowable:initiator="applyUser"/>
<sequenceFlow id="flow1" sourceRef="startEvent" targetRef="applyTask"/>
<!-- 申请节点:员工填写请假单 -->
<userTask id="applyTask" name="填写请假单" flowable:assignee="${applyUser}">
<extensionElements>
<!-- 关键点1: 定义外置表单的Key -->
<flowable:formKey>leave-apply-form</flowable:formKey>
</extensionElements>
</userTask>
<sequenceFlow id="flow2" sourceRef="applyTask" targetRef="approvalTask"/>
<!-- 审批节点:领导审批 -->
<userTask id="approvalTask" name="领导审批" flowable:assignee="manager">
<extensionElements>
<!-- 关键点2: 定义审批表单的Key -->
<flowable:formKey>leave-approval-form</flowable:formKey>
</extensionElements>
</userTask>
<sequenceFlow id="flow3" sourceRef="approvalTask" targetRef="decision"/>
<!-- 排他网关:根据审批结果决定走向 -->
<exclusiveGateway id="decision"/>
<!-- 如果同意,走向结束 -->
<sequenceFlow id="flowApproved" sourceRef="decision" targetRef="endEvent">
<conditionExpression xsi:type="tFormalExpression">
<![CDATA[${approved == true}]]>
</conditionExpression>
</sequenceFlow>
<!-- 如果拒绝,回到申请节点(重新提交) -->
<sequenceFlow id="flowRejected" sourceRef="decision" targetRef="applyTask">
<conditionExpression xsi:type="tFormalExpression">
<![CDATA[${approved == false}]]>
</conditionExpression>
</sequenceFlow>
<endEvent id="endEvent"/>
</process>
</definitions>
注意:
我们将这个 .bpmn20.xml 文件放到 Spring Boot 项目的 src/main/resources/processes/ 目录下。
我们这里为了演示,把“发起”和“填写表单”分开了。在实际中,常常是发起流程时就直接提交了表单数据,从而直接完成第一个任务。我们这里的演示流程是:发起 -> 任务列表出现“填写请假单” -> 填写并提交。
第 2 步:后端 Spring Boot 开发
创建 REST API 接口 (LeaveController.java)
这是连接前后端的桥梁。
@RestController
@RequestMapping("/api/leave")
@RequiredArgsConstructor // 使用Lombok注入
@CrossOrigin // 允许跨域,方便本地Vue开发调试
public class LeaveController {
private final RuntimeService runtimeService;
private final TaskService taskService;
/**
* 1. 启动请假流程
* @param userId 申请人ID
*/
@PostMapping("/start/{userId}")
public String startProcess(@PathVariable String userId) {
// 流程变量,设置申请人
Map<String, Object> variables = new HashMap<>();
variables.put("applyUser", userId);
ProcessInstance processInstance = runtimeService.startProcessInstanceByKey("leaveProcess", variables);
return "流程已启动,实例ID: " + processInstance.getId();
}
/**
* 2. 获取用户的待办任务列表
* @param userId 用户ID
* @return 任务列表
*/
@GetMapping("/tasks/{userId}")
public List<Map<String, Object>> getTasks(@PathVariable String userId) {
List<Task> tasks = taskService.createTaskQuery().taskAssignee(userId).orderByTaskCreateTime().desc().list();
// 返回给前端一个简化的DTO列表
return tasks.stream().map(task -> {
Map<String, Object> map = new HashMap<>();
map.put("taskId", task.getId());
map.put("taskName", task.getName());
map.put("createTime", task.getCreateTime());
map.put("formKey", task.getFormKey()); // **核心:把formKey传给前端**
map.put("processInstanceId", task.getProcessInstanceId());
return map;
}).collect(Collectors.toList());
}
/**
* 3. 获取任务关联的流程变量 (用于表单数据回显)
* @param taskId 任务ID
* @return 流程变量
*/
@GetMapping("/tasks/{taskId}/variables")
public Map<String, Object> getTaskVariables(@PathVariable String taskId) {
return taskService.getVariables(taskId);
}
/**
* 4. 完成任务 (提交表单)
* @param taskId 任务ID
* @param variables 表单提交的数据,以JSON格式的Map传入
*/
@PostMapping("/tasks/{taskId}/complete")
public void completeTask(@PathVariable String taskId, @RequestBody Map<String, Object> variables) {
taskService.complete(taskId, variables);
}
}
第 3 步:前端 Vue 开发
假设你已经创建了一个 Vue 项目,并安装了 vue-router 和 axios。
- API 服务 (src/api/leave.js)
将后端接口封装成函数,便于组件调用。
import axios from 'axios';
const apiClient = axios.create({
baseURL: 'http://localhost:8080/api/leave', // 你的Spring Boot地址
headers: {
'Content-Type': 'application/json',
},
});
export default {
startProcess(userId) {
return apiClient.post(`/start/${userId}`);
},
getTasks(userId) {
return apiClient.get(`/tasks/${userId}`);
},
getTaskVariables(taskId) {
return apiClient.get(`/tasks/${taskId}/variables`);
},
completeTask(taskId, variables) {
return apiClient.post(`/tasks/${taskId}/complete`, variables);
},
};
- Vue Router 配置 (src/router/index.js)
这是实现 formKey 动态路由的核心。
import { createRouter, createWebHistory } from 'vue-router';
import TaskList from '../views/TaskList.vue';
import FormWrapper from '../views/FormWrapper.vue'; // 一个动态加载表单的包装器组件
const routes = [
{
path: '/',
redirect: '/tasks/employee', // 默认重定向到员工的任务列表
},
{
path: '/tasks/:userId', // 任务列表页,:userId是动态的
name: 'TaskList',
component: TaskList,
props: true, // 将路由参数userId作为props传递给组件
},
{
// **核心路由:根据formKey动态展示表单**
path: '/form/:formKey',
name: 'Form',
component: FormWrapper,
props: true,
},
];
const router = createRouter({
history: createWebHistory(),
routes,
});
export default router;
- 视图组件
a. TaskList.vue (任务列表)
<template>
<div>
<h2>{{ userId }} 的待办任务</h2>
<button @click="startLeaveProcess" v-if="userId === 'employee'">发起请假流程</button>
<ul>
<li v-for="task in tasks" :key="task.taskId">
{{ task.taskName }} - 创建时间: {{ new Date(task.createTime).toLocaleString() }}
<button @click="handleTask(task)">去处理</button>
</li>
</ul>
</div>
</template>
<script>
import api from '../api/leave';
export default {
props: ['userId'],
data() {
return {
tasks: [],
};
},
methods: {
async fetchTasks() {
const response = await api.getTasks(this.userId);
this.tasks = response.data;
},
async startLeaveProcess() {
try {
await api.startProcess(this.userId);
alert('流程已启动!');
this.fetchTasks(); // 刷新任务列表
} catch (error) {
console.error("启动流程失败:", error);
}
},
handleTask(task) {
// **关键跳转:使用formKey和taskId导航到表单页**
this.$router.push({
name: 'Form',
params: { formKey: task.formKey },
query: { taskId: task.taskId },
});
},
},
created() {
this.fetchTasks();
},
watch: {
// 监听userId变化,例如从 /tasks/employee 切换到 /tasks/manager
userId() {
this.fetchTasks();
}
}
};
</script>
b. FormWrapper.vue (表单包装器/分发器)
这个组件非常巧妙,它根据路由参数 formKey 来决定到底渲染哪个具体的表单组件。
<template>
<div>
<!-- Vue的动态组件,:is 属性绑定到要渲染的组件 -->
<component :is="formComponent" :task-id="taskId" />
</div>
</template>
<script>
import { defineAsyncComponent } from 'vue';
// 定义formKey到组件的映射
const formMap = {
'leave-apply-form': defineAsyncComponent(() => import('../components/LeaveApplyForm.vue')),
'leave-approval-form': defineAsyncComponent(() => import('../components/LeaveApprovalForm.vue')),
};
export default {
props: ['formKey'],
computed: {
formComponent() {
return formMap[this.formKey] || null; // 根据formKey返回对应的组件
},
taskId() {
return this.$route.query.taskId; // 从路由的query中获取taskId
},
},
};
</script>
c. LeaveApplyForm.vue (请假申请表单)
<template>
<div class="form-container">
<h3>填写请假单</h3>
<p>任务ID: {{ taskId }}</p>
<div>
<label>请假天数:</label>
<input type="number" v-model.number="formData.days" />
</div>
<div>
<label>请假事由:</label>
<textarea v-model="formData.reason"></textarea>
</div>
<button @click="submitForm">提交申请</button>
</div>
</template>
<script>
import api from '../api/leave';
export default {
props: ['taskId'],
data() {
return {
formData: {
days: 0,
reason: '',
},
};
},
methods: {
async submitForm() {
try {
await api.completeTask(this.taskId, this.formData);
alert('申请已提交!');
this.$router.push('/tasks/employee'); // 提交后返回任务列表
} catch (error) {
console.error('提交失败:', error);
}
},
},
};
</script>
d. LeaveApprovalForm.vue (领导审批表单)
这个表单需要先加载数据显示,然后再提交。
<template>
<div class="form-container">
<h3>审批请假单</h3>
<div v-if="loading">正在加载数据...</div>
<div v-else>
<p><strong>申请人:</strong> {{ variables.applyUser }}</p>
<p><strong>请假天数:</strong> {{ variables.days }} 天</p>
<p><strong>请假事由:</strong> {{ variables.reason }}</p>
<hr />
<div>
<label>审批意见:</label>
<textarea v-model="approval.comment"></textarea>
</div>
<button @click="handleApproval(true)">同意</button>
<button @click="handleApproval(false)" style="background-color: red;">拒绝</button>
</div>
</div>
</template>
<script>
import api from '../api/leave';
export default {
props: ['taskId'],
data() {
return {
loading: true,
variables: {}, // 存放从后端加载的流程变量
approval: {
comment: '',
approved: null, // 将在点击按钮时设置
},
};
},
methods: {
async fetchVariables() {
try {
const response = await api.getTaskVariables(this.taskId);
this.variables = response.data;
} catch (error) {
console.error('加载表单数据失败:', error);
} finally {
this.loading = false;
}
},
async handleApproval(isApproved) {
this.approval.approved = isApproved;
try {
await api.completeTask(this.taskId, this.approval);
alert('审批完成!');
this.$router.push('/tasks/manager'); // 返回领导的任务列表
} catch (error) {
console.error('审批失败:', error);
}
},
},
created() {
this.fetchVariables(); // 组件创建时加载数据
},
};
</script>
第 4 步:演示整个流程
启动 Spring Boot 后端。
启动 Vue 前端。
员工操作:
浏览器访问 http://localhost:8081/tasks/employee (假设Vue运行在8081端口)。
点击 “发起请假流程”,后端启动流程实例,任务列表刷新,出现 “填写请假单” 任务。
点击 “去处理”,Vue Router 根据 formKey (leave-apply-form) 导航到表单页,FormWrapper 加载 LeaveApplyForm.vue。
填写天数和事由,点击 “提交申请”。数据作为流程变量提交,第一个任务完成。
领导操作:
浏览器访问 http://localhost:8081/tasks/manager。
任务列表显示 “领导审批” 任务。
点击 “去处理”,Vue Router 根据 formKey (leave-approval-form) 导航,FormWrapper 加载 LeaveApprovalForm.vue。
LeaveApprovalForm 组件会调用后端API,获取并显示员工提交的请假天数和事由(数据回显)。
领导填写审批意见,点击 “同意”。approved: true 和 comment 作为流程变量提交,任务完成。
流程结束:
流程引擎根据 approved == true 的条件,流向结束节点。
如果领导点击 “拒绝”,流程会回到 “填写请假单” 节点,员工会在自己的任务列表里再次看到这个任务。
前端架构的核心思想
前端的核心任务是:根据后端传来的 formKey,动态地展示正确的表单组件,并处理好数据的加载和提交。
为了实现这个目标,我们设计了以下几个关键部分:
API 服务层 (api/leave.js): 专职与后端打交道,让组件代码更干净。
路由 (router/index.js): 充当交通警察的角色。它负责解析 URL,并将用户引导到正确的“页面”或“视图”。
任务列表视图 (views/TaskList.vue): 用户的工作台。展示所有待办事项,是所有操作的入口。
表单分发器/包装器 (views/FormWrapper.vue): 这是整个前端设计的**“枢纽”或“魔法盒”。它本身不展示任何具体表单,而是根据指令(formKey)决定调用哪个真正的表单组件。
具体表单组件 (components/Leave*.vue): 真正负责 UI 展示和用户交互的“工人”**。
用户在 TaskList.vue 点击 "去处理"
│
└─> 1. handleTask(task) 被调用
│
└─> 2. this.$router.push({ name: 'Form', params: { formKey: '...' }, query: { taskId: '...' }})
│
└─> 3. Vue Router 匹配到 /form/:formKey 路由,浏览器 URL 变化
│
└─> 4. FormWrapper.vue 组件被渲染
│
├─> 5. FormWrapper 从路由接收 props: { formKey: '...' }
│
└─> 6. FormWrapper 从路由 query 中获取 taskId: '...'
│
└─> 7. formComponent 计算属性根据 formKey 从 formMap 中找到对应的组件(如 LeaveApprovalForm)
│
└─> 8. <component :is="LeaveApprovalForm" :task-id="taskId"> 被渲染
│
└─> 9. LeaveApprovalForm.vue 组件被创建
│
├─> 10. 它接收到 taskId 这个 prop
│
└─> 11. 在 created() 钩子中,使用 this.taskId 调用 API (getTaskVariables) 加载数据
│
└─> 12. 用户填写表单,点击提交
│
└─> 13. 调用 API (completeTask) 并携带 taskId 和表单数据
│
└─> 14. 提交成功后,路由跳转回任务列表