feat: add visual process designer
This commit is contained in:
@@ -4,6 +4,7 @@ React + TypeScript 管理端,当前提供两个配置工作台:
|
||||
|
||||
- 可视化表单设计器:字段添加与排序、属性配置、移动端卡片预览、Schema 生成、草稿保存和版本发布。
|
||||
- 流程选择与绑定:选择已发布表单和 Flowable 定义,配置业务类型、请假类型、时长范围、优先级以及绑定启停。
|
||||
- 可视化流程设计器:使用受约束模板组合串行审批、并行会签和条件审批,校验后直接部署为新的 Flowable 流程版本。
|
||||
|
||||
流程绑定仅负责从权威业务数据选择已经部署的流程定义;客户端不能指定流程,已启动实例也不会因绑定变化而切换流程版本。
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { api } from './api';
|
||||
import { userManager } from './auth';
|
||||
import { buildSchemas, type ControlType, type Field } from './schema';
|
||||
import { ProcessBindings } from './ProcessBindings';
|
||||
import { ProcessDesigner } from './ProcessDesigner';
|
||||
|
||||
type FormVersion = { form_key: string; version: number; status: string; published_at?: string };
|
||||
const palette: { control: ControlType; label: string }[] = [
|
||||
@@ -29,8 +30,8 @@ function Login() {
|
||||
}
|
||||
|
||||
function Workspace({ onLogout }: { onLogout: () => void }) {
|
||||
const [view, setView] = useState<'forms' | 'bindings'>('forms');
|
||||
return <div className="workspace"><header><div><strong>AIOA</strong><span>管理设计中心</span></div><nav><button className={view === 'forms' ? 'navActive' : 'navButton'} onClick={() => setView('forms')}>表单设计</button><button className={view === 'bindings' ? 'navActive' : 'navButton'} onClick={() => setView('bindings')}>流程绑定</button></nav><button className="ghost" onClick={onLogout}>退出</button></header>{view === 'forms' ? <Designer /> : <ProcessBindings />}</div>;
|
||||
const [view, setView] = useState<'forms' | 'processes' | 'bindings'>('forms');
|
||||
return <div className="workspace"><header><div><strong>AIOA</strong><span>管理设计中心</span></div><nav><button className={view === 'forms' ? 'navActive' : 'navButton'} onClick={() => setView('forms')}>表单设计</button><button className={view === 'processes' ? 'navActive' : 'navButton'} onClick={() => setView('processes')}>流程设计</button><button className={view === 'bindings' ? 'navActive' : 'navButton'} onClick={() => setView('bindings')}>流程绑定</button></nav><button className="ghost" onClick={onLogout}>退出</button></header>{view === 'forms' ? <Designer /> : view === 'processes' ? <ProcessDesigner /> : <ProcessBindings />}</div>;
|
||||
}
|
||||
|
||||
function Designer() {
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { api } from './api';
|
||||
|
||||
type Mode = 'SERIAL' | 'PARALLEL' | 'CONDITIONAL';
|
||||
type Assignee = 'approverId' | 'oaAdministratorId' | 'hrReviewerId';
|
||||
type Step = { id: string; name: string; assigneeVariable: Assignee };
|
||||
const assignees: { value: Assignee; label: string }[] = [
|
||||
{ value: 'approverId', label: '部门主管' }, { value: 'oaAdministratorId', label: 'OA 管理员' }, { value: 'hrReviewerId', label: 'HR 复核人' },
|
||||
];
|
||||
const modes: { value: Mode; label: string; hint: string }[] = [
|
||||
{ value: 'SERIAL', label: '串行审批', hint: '依次审批,任一驳回即结束' },
|
||||
{ value: 'PARALLEL', label: '并行会签', hint: '同时审批,全部通过才结束' },
|
||||
{ value: 'CONDITIONAL', label: '条件审批', hint: '达到时长阈值后增加复核' },
|
||||
];
|
||||
|
||||
export function ProcessDesigner() {
|
||||
const [key, setKey] = useState('leaveApprovalCustom');
|
||||
const [name, setName] = useState('自定义请假审批');
|
||||
const [mode, setMode] = useState<Mode>('SERIAL');
|
||||
const [thresholdDays, setThresholdDays] = useState(3);
|
||||
const [steps, setSteps] = useState<Step[]>([
|
||||
{ id: crypto.randomUUID(), name: '部门主管审批', assigneeVariable: 'approverId' },
|
||||
{ id: crypto.randomUUID(), name: 'OA 管理员复核', assigneeVariable: 'oaAdministratorId' },
|
||||
]);
|
||||
const [selected, setSelected] = useState(steps[0].id);
|
||||
const [message, setMessage] = useState('');
|
||||
const selectedStep = steps.find(step => step.id === selected);
|
||||
const effectiveSteps = mode === 'CONDITIONAL' ? steps.slice(0, 2) : steps;
|
||||
const summary = useMemo(() => modes.find(item => item.value === mode)?.hint, [mode]);
|
||||
|
||||
function changeMode(next: Mode) {
|
||||
setMode(next);
|
||||
if (next === 'CONDITIONAL' && steps.length < 2) setSteps([...steps, { id: crypto.randomUUID(), name: 'OA 管理员复核', assigneeVariable: 'oaAdministratorId' }]);
|
||||
}
|
||||
function update(patch: Partial<Step>) { setSteps(steps.map(step => step.id === selected ? { ...step, ...patch } : step)); }
|
||||
function addStep() {
|
||||
const step: Step = { id: crypto.randomUUID(), name: `审批节点 ${steps.length + 1}`, assigneeVariable: 'approverId' };
|
||||
setSteps([...steps, step]); setSelected(step.id);
|
||||
}
|
||||
function move(id: string, delta: number) {
|
||||
const from = steps.findIndex(step => step.id === id), to = from + delta;
|
||||
if (to < 0 || to >= steps.length) return;
|
||||
const next = [...steps]; [next[from], next[to]] = [next[to], next[from]]; setSteps(next);
|
||||
}
|
||||
async function deploy() {
|
||||
try {
|
||||
const result = await api<{ key: string; version: number }>('/admin/process-configuration/processes/deploy', { method: 'POST', body: JSON.stringify({
|
||||
key, name, mode, steps: effectiveSteps.map(({ name: stepName, assigneeVariable }) => ({ name: stepName, assigneeVariable })),
|
||||
conditionVariable: 'durationMinutes', conditionThreshold: Math.round(thresholdDays * 480),
|
||||
}) });
|
||||
setMessage(`${result.key} v${result.version} 已部署到 Flowable`);
|
||||
} catch (error) { setMessage(error instanceof Error ? error.message : '部署失败'); }
|
||||
}
|
||||
|
||||
return <section className="processDesignerPage">
|
||||
{message && <div className="toast" onClick={() => setMessage('')}>{message}</div>}
|
||||
<div className="processTop"><div><h1>可视化流程设计器</h1><p>使用安全模板设计串行、并行和条件审批,并直接发布到 Flowable。</p></div><button onClick={deploy}>校验并部署</button></div>
|
||||
<section className="processMeta"><label>流程 Key<input value={key} onChange={e => setKey(e.target.value)} /></label><label>流程名称<input value={name} onChange={e => setName(e.target.value)} /></label></section>
|
||||
<div className="modeSelector">{modes.map(item => <button key={item.value} className={mode === item.value ? 'modeActive' : 'modeButton'} onClick={() => changeMode(item.value)}><strong>{item.label}</strong><span>{item.hint}</span></button>)}</div>
|
||||
<div className="processWorkspace">
|
||||
<div className="processCanvas"><div className="templateHint"><strong>{modes.find(item => item.value === mode)?.label}</strong><span>{summary}</span></div>
|
||||
<div className={`processGraph ${mode.toLowerCase()}`}><ProcessNode kind="start" title="开始" subtitle="业务已提交" /><Arrow />
|
||||
{mode === 'PARALLEL' && <><ProcessNode kind="gateway" title="并行拆分" subtitle="同时创建任务" /><Arrow /></>}
|
||||
<div className={mode === 'PARALLEL' ? 'parallelBranches' : 'linearNodes'}>{effectiveSteps.map((step, index) => <div className="graphStep" key={step.id}><button className={`approvalNode ${selected === step.id ? 'nodeSelected' : ''}`} onClick={() => setSelected(step.id)}><span>{index + 1}</span><strong>{step.name}</strong><small>{assignees.find(item => item.value === step.assigneeVariable)?.label}</small></button>{mode !== 'PARALLEL' && index < effectiveSteps.length - 1 && <Arrow label={mode === 'CONDITIONAL' ? `>${thresholdDays}天` : undefined} />}</div>)}</div>
|
||||
{mode === 'PARALLEL' && <><Arrow /><ProcessNode kind="gateway" title="并行汇聚" subtitle="全部通过" /></>}<Arrow /><ProcessNode kind="end" title="结束" subtitle="批准 / 驳回" /></div>
|
||||
</div>
|
||||
<aside className="processProperties"><h2>流程属性</h2>{mode === 'CONDITIONAL' && <label>时长阈值(天)<input type="number" min="0" step="0.5" value={thresholdDays} onChange={e => setThresholdDays(Number(e.target.value))} /></label>}<h2>审批节点</h2>{selectedStep ? <><label>节点名称<input value={selectedStep.name} onChange={e => update({ name: e.target.value })} /></label><label>审批人规则<select value={selectedStep.assigneeVariable} onChange={e => update({ assigneeVariable: e.target.value as Assignee })}>{assignees.map(item => <option key={item.value} value={item.value}>{item.label}</option>)}</select></label><div className="nodeActions"><button onClick={() => move(selectedStep.id, -1)}>上移</button><button onClick={() => move(selectedStep.id, 1)}>下移</button><button className="dangerGhost" disabled={effectiveSteps.length <= (mode === 'CONDITIONAL' ? 2 : 1)} onClick={() => setSteps(steps.filter(step => step.id !== selectedStep.id))}>删除</button></div></> : <p>请选择审批节点</p>}{mode !== 'CONDITIONAL' && steps.length < 6 && <button className="primaryWide" onClick={addStep}>+ 添加审批节点</button>}<div className="securityNote"><strong>安全约束</strong><p>审批人只能选择组织角色映射;条件变量和表达式由后端生成,不能输入任意脚本。</p></div></aside>
|
||||
</div>
|
||||
</section>;
|
||||
}
|
||||
|
||||
function ProcessNode({ kind, title, subtitle }: { kind: string; title: string; subtitle: string }) { return <div className={`templateNode ${kind}`}><strong>{title}</strong><small>{subtitle}</small></div>; }
|
||||
function Arrow({ label }: { label?: string }) { return <div className="graphArrow">{label && <span>{label}</span>}→</div>; }
|
||||
File diff suppressed because one or more lines are too long
+8
@@ -45,6 +45,14 @@ class ProcessConfigurationController(
|
||||
audit.recordSuccess(a,"PROCESS_BINDING_CREATED","PROCESS_BINDING",id.toString(),null,mapOf("processDefinitionKey" to body.processDefinitionKey,"formVersion" to body.formVersion));return mapOf("id" to id,"status" to "ACTIVE")
|
||||
}
|
||||
@PutMapping("/bindings/{id}/status") fun status(@AuthenticationPrincipal jwt:Jwt,@PathVariable id:UUID,@RequestBody body:BindingStatusCommand) { val a=actor(jwt);if(body.status !in setOf("ACTIVE","INACTIVE")) throw ApiException(HttpStatus.BAD_REQUEST,"BINDING_STATUS_INVALID","状态无效");if(dsl.execute("UPDATE workflow.process_binding SET status=?,updated_at=CURRENT_TIMESTAMP WHERE tenant_id=? AND id=?",body.status,a.tenantId,id)!=1) throw ApiException(HttpStatus.NOT_FOUND,"PROCESS_BINDING_NOT_FOUND","绑定不存在");audit.recordSuccess(a,"PROCESS_BINDING_STATUS_CHANGED","PROCESS_BINDING",id.toString(),null,mapOf("status" to body.status)) }
|
||||
@PostMapping("/processes/deploy") fun deploy(@AuthenticationPrincipal jwt:Jwt,@RequestBody body:ProcessTemplateCommand):Map<String,Any?> {
|
||||
val a=actor(jwt);val xml=generateProcessTemplate(body)
|
||||
val deployment=flowable.createDeployment().name("AIOA Designer: ${body.name}").addString("${body.key}.bpmn20.xml",xml).deploy()
|
||||
val definition=flowable.createProcessDefinitionQuery().deploymentId(deployment.id).singleResult()
|
||||
?: throw ApiException(HttpStatus.INTERNAL_SERVER_ERROR,"PROCESS_DEPLOYMENT_FAILED","流程部署失败")
|
||||
audit.recordSuccess(a,"PROCESS_DEFINITION_DEPLOYED","PROCESS_DEFINITION",definition.id,null,mapOf("key" to definition.key,"version" to definition.version,"mode" to body.mode))
|
||||
return mapOf("id" to definition.id,"key" to definition.key,"version" to definition.version,"deploymentId" to deployment.id)
|
||||
}
|
||||
private fun validateSchema(data:Map<String,Any>,ui:Map<String,Any>){if(data["type"]!="object"||data["properties"] !is Map<*,*>||ui["sections"] !is List<*>) throw ApiException(HttpStatus.BAD_REQUEST,"FORM_SCHEMA_INVALID","表单 Schema 结构无效")}
|
||||
}
|
||||
data class FormVersionCommand(val formKey:String,val dataSchema:Map<String,Any>,val uiSchema:Map<String,Any>)
|
||||
|
||||
+82
@@ -0,0 +1,82 @@
|
||||
package com.all8ai.aioa.admin.configuration
|
||||
|
||||
import com.all8ai.aioa.shared.web.ApiException
|
||||
import org.springframework.http.HttpStatus
|
||||
|
||||
data class ApprovalStepCommand(val name:String,val assigneeVariable:String)
|
||||
data class ProcessTemplateCommand(
|
||||
val key:String,val name:String,val mode:String,val steps:List<ApprovalStepCommand>,
|
||||
val conditionVariable:String="durationMinutes",val conditionThreshold:Long=1440,
|
||||
)
|
||||
|
||||
private val safeKey=Regex("[A-Za-z][A-Za-z0-9_-]{2,63}")
|
||||
private val allowedAssignees=setOf("approverId","oaAdministratorId","hrReviewerId")
|
||||
private val allowedConditions=setOf("durationMinutes")
|
||||
|
||||
fun generateProcessTemplate(command:ProcessTemplateCommand):String {
|
||||
if(!safeKey.matches(command.key)) invalid("流程 Key 必须以字母开头且只能包含字母、数字、下划线或连字符")
|
||||
if(command.name.isBlank()||command.name.length>100) invalid("流程名称长度无效")
|
||||
if(command.mode !in setOf("SERIAL","PARALLEL","CONDITIONAL")) invalid("流程模式无效")
|
||||
if(command.mode=="CONDITIONAL" && command.steps.size!=2 || command.mode!="CONDITIONAL" && command.steps.size !in 1..6) invalid("审批节点数量无效")
|
||||
if(command.steps.any{it.name.isBlank()||it.name.length>80||it.assigneeVariable !in allowedAssignees}) invalid("审批节点配置无效")
|
||||
if(command.conditionVariable !in allowedConditions||command.conditionThreshold<0) invalid("条件配置无效")
|
||||
val body=when(command.mode){
|
||||
"SERIAL"->serial(command.steps)
|
||||
"PARALLEL"->parallel(command.steps)
|
||||
else->conditional(command.steps,command.conditionVariable,command.conditionThreshold)
|
||||
}
|
||||
return """<?xml version="1.0" encoding="UTF-8"?>
|
||||
<definitions xmlns="http://www.omg.org/spec/BPMN/20100524/MODEL" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:flowable="http://flowable.org/bpmn" targetNamespace="https://aioa.all8ai.com/designer">
|
||||
<process id="${command.key}" name="${xml(command.name)}" isExecutable="true">
|
||||
$body
|
||||
</process>
|
||||
</definitions>"""
|
||||
}
|
||||
|
||||
private fun serial(steps:List<ApprovalStepCommand>):String=buildString {
|
||||
appendLine(" <startEvent id=\"start\" name=\"已提交\"/>")
|
||||
appendLine(" <sequenceFlow id=\"flow-start-task0\" sourceRef=\"start\" targetRef=\"task0\"/>")
|
||||
steps.forEachIndexed { index,step ->
|
||||
appendDecisionTask(index,step,if(index==steps.lastIndex) "approvedEnd" else "task${index+1}")
|
||||
}
|
||||
appendEnds()
|
||||
}.trimEnd()
|
||||
|
||||
private fun parallel(steps:List<ApprovalStepCommand>):String=buildString {
|
||||
appendLine(" <startEvent id=\"start\" name=\"已提交\"/>")
|
||||
appendLine(" <sequenceFlow id=\"flow-start-split\" sourceRef=\"start\" targetRef=\"parallelSplit\"/>")
|
||||
appendLine(" <parallelGateway id=\"parallelSplit\" name=\"并行会签\"/>")
|
||||
steps.forEachIndexed { index,step ->
|
||||
appendLine(" <sequenceFlow id=\"flow-split-task$index\" sourceRef=\"parallelSplit\" targetRef=\"task$index\"/>")
|
||||
appendDecisionTask(index,step,"parallelJoin")
|
||||
}
|
||||
appendLine(" <parallelGateway id=\"parallelJoin\" name=\"全部通过\"/>")
|
||||
appendLine(" <sequenceFlow id=\"flow-join-end\" sourceRef=\"parallelJoin\" targetRef=\"approvedEnd\"/>")
|
||||
appendEnds()
|
||||
}.trimEnd()
|
||||
|
||||
private fun conditional(steps:List<ApprovalStepCommand>,variable:String,threshold:Long):String=buildString {
|
||||
appendLine(" <startEvent id=\"start\" name=\"已提交\"/>")
|
||||
appendLine(" <sequenceFlow id=\"flow-start-task0\" sourceRef=\"start\" targetRef=\"task0\"/>")
|
||||
appendDecisionTask(0,steps[0],"routeDecision")
|
||||
appendLine(" <exclusiveGateway id=\"routeDecision\" name=\"条件路由\"/>")
|
||||
appendLine(" <sequenceFlow id=\"flow-condition-short\" sourceRef=\"routeDecision\" targetRef=\"approvedEnd\"><conditionExpression xsi:type=\"tFormalExpression\"><![CDATA[\${$variable <= $threshold}]]></conditionExpression></sequenceFlow>")
|
||||
appendLine(" <sequenceFlow id=\"flow-condition-long\" sourceRef=\"routeDecision\" targetRef=\"task1\"><conditionExpression xsi:type=\"tFormalExpression\"><![CDATA[\${$variable > $threshold}]]></conditionExpression></sequenceFlow>")
|
||||
appendDecisionTask(1,steps[1],"approvedEnd")
|
||||
appendEnds()
|
||||
}.trimEnd()
|
||||
|
||||
private fun StringBuilder.appendDecisionTask(index:Int,step:ApprovalStepCommand,approvedTarget:String) {
|
||||
appendLine(" <userTask id=\"task$index\" name=\"${xml(step.name)}\" flowable:assignee=\"\${${step.assigneeVariable}}\"/>")
|
||||
appendLine(" <sequenceFlow id=\"flow-task$index-decision\" sourceRef=\"task$index\" targetRef=\"decision$index\"/>")
|
||||
appendLine(" <exclusiveGateway id=\"decision$index\" name=\"审批结果\"/>")
|
||||
appendLine(" <sequenceFlow id=\"flow-task$index-approved\" sourceRef=\"decision$index\" targetRef=\"$approvedTarget\"><conditionExpression xsi:type=\"tFormalExpression\"><![CDATA[\${approved == true}]]></conditionExpression></sequenceFlow>")
|
||||
appendLine(" <sequenceFlow id=\"flow-task$index-rejected\" sourceRef=\"decision$index\" targetRef=\"rejectedEnd\"><conditionExpression xsi:type=\"tFormalExpression\"><![CDATA[\${approved == false}]]></conditionExpression></sequenceFlow>")
|
||||
}
|
||||
|
||||
private fun StringBuilder.appendEnds(){
|
||||
appendLine(" <endEvent id=\"approvedEnd\" name=\"已批准\"/>")
|
||||
appendLine(" <endEvent id=\"rejectedEnd\" name=\"已驳回\"><terminateEventDefinition/></endEvent>")
|
||||
}
|
||||
private fun xml(value:String)=value.replace("&","&").replace("<","<").replace(">",">").replace("\"",""")
|
||||
private fun invalid(message:String):Nothing=throw ApiException(HttpStatus.BAD_REQUEST,"PROCESS_TEMPLATE_INVALID",message)
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
package com.all8ai.aioa.admin.configuration
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertContains
|
||||
import kotlin.test.assertFails
|
||||
|
||||
class ProcessTemplateGeneratorTest {
|
||||
private val manager=ApprovalStepCommand("主管审批","approverId")
|
||||
private val oa=ApprovalStepCommand("OA 复核","oaAdministratorId")
|
||||
|
||||
@Test fun `generates serial approval with rejection path`() {
|
||||
val xml=generateProcessTemplate(ProcessTemplateCommand("serialDemo","串行审批","SERIAL",listOf(manager,oa)))
|
||||
assertContains(xml,"sourceRef=\"decision0\" targetRef=\"task1\"")
|
||||
assertContains(xml,"targetRef=\"rejectedEnd\"")
|
||||
}
|
||||
|
||||
@Test fun `generates parallel gateways and condition expressions`() {
|
||||
val parallel=generateProcessTemplate(ProcessTemplateCommand("parallelDemo","并行审批","PARALLEL",listOf(manager,oa)))
|
||||
assertContains(parallel,"parallelGateway id=\"parallelSplit\"")
|
||||
assertContains(parallel,"parallelGateway id=\"parallelJoin\"")
|
||||
val conditional=generateProcessTemplate(ProcessTemplateCommand("conditionalDemo","条件审批","CONDITIONAL",listOf(manager,oa),conditionThreshold=960))
|
||||
assertContains(conditional,"durationMinutes <= 960")
|
||||
}
|
||||
|
||||
@Test fun `rejects unsafe identifiers and assignee expressions`() {
|
||||
assertFails { generateProcessTemplate(ProcessTemplateCommand("bad key","测试","SERIAL",listOf(manager))) }
|
||||
assertFails { generateProcessTemplate(ProcessTemplateCommand("safeKey","测试","SERIAL",listOf(ApprovalStepCommand("审批","evilExpression")))) }
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: AIOA API
|
||||
version: 0.15.0
|
||||
version: 0.16.0
|
||||
servers:
|
||||
- url: /api/v1
|
||||
security:
|
||||
@@ -17,6 +17,8 @@ paths:
|
||||
post: { operationId: createProcessBinding, summary: 创建业务流程路由绑定, responses: { "200": { description: 已创建并启用绑定 } } }
|
||||
/admin/process-configuration/bindings/{id}/status:
|
||||
put: { operationId: updateProcessBindingStatus, summary: 启用或停用流程绑定, parameters: [{ name: id, in: path, required: true, schema: { type: string, format: uuid } }], responses: { "200": { description: 状态已更新 } } }
|
||||
/admin/process-configuration/processes/deploy:
|
||||
post: { operationId: deployProcessTemplate, summary: 校验并部署受约束的可视化流程模板, responses: { "200": { description: Flowable 流程定义已部署 } } }
|
||||
/admin/organization/departments:
|
||||
get: { operationId: listAdminDepartments, summary: OA 管理员查询租户部门, responses: { "200": { description: 部门列表 } } }
|
||||
post: { operationId: createAdminDepartment, summary: OA 管理员创建部门, responses: { "200": { description: 已创建部门 } } }
|
||||
|
||||
@@ -59,6 +59,7 @@
|
||||
- [x] React Web 可视化表单设计器、移动卡片预览和版本发布
|
||||
- [x] Web OIDC Authorization Code + PKCE 登录、设备会话和 CI 验证
|
||||
- [x] Web 流程选择与表单绑定、条件路由预览和绑定启停
|
||||
- [x] Web 串行、并行、条件流程可视化设计与受约束 Flowable 部署
|
||||
|
||||
## Definition of Done
|
||||
|
||||
|
||||
@@ -51,3 +51,12 @@ Flowable 负责任务和流程路径,`business.leave_request` 仍是请假业
|
||||
```
|
||||
|
||||
该流程已经验证条件网关、多级串行、并行拆分、并行汇聚和终止事件。后端状态同步按通用多任务语义设计,不会在第一个串行或并行任务完成时提前结束申请。
|
||||
## 可视化流程模板
|
||||
|
||||
Web 管理端提供受约束的流程设计器,可生成并部署以下 Flowable BPMN 模板:
|
||||
|
||||
- 串行审批:审批节点依次执行,任一节点驳回即终止。
|
||||
- 并行会签:多个审批节点同时执行,全部通过后汇聚,任一驳回即终止其他分支。
|
||||
- 条件审批:首个审批通过后按请假时长阈值决定直接结束或增加复核节点。
|
||||
|
||||
流程 Key、节点数量、审批人变量、条件变量和阈值均由后端校验。管理端不能上传任意 BPMN XML、任意表达式或任意审批人脚本。部署成功后,新定义立即出现在流程绑定工作台中。
|
||||
|
||||
Reference in New Issue
Block a user