feat: add visual process designer

This commit is contained in:
selfrelease
2026-07-18 22:56:29 +08:00
parent 89c44a61a7
commit 9c366cc872
10 changed files with 210 additions and 4 deletions
+1
View File
@@ -4,6 +4,7 @@ React + TypeScript 管理端,当前提供两个配置工作台:
- 可视化表单设计器:字段添加与排序、属性配置、移动端卡片预览、Schema 生成、草稿保存和版本发布。
- 流程选择与绑定:选择已发布表单和 Flowable 定义,配置业务类型、请假类型、时长范围、优先级以及绑定启停。
- 可视化流程设计器:使用受约束模板组合串行审批、并行会签和条件审批,校验后直接部署为新的 Flowable 流程版本。
流程绑定仅负责从权威业务数据选择已经部署的流程定义;客户端不能指定流程,已启动实例也不会因绑定变化而切换流程版本。
+3 -2
View File
@@ -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() {
+73
View File
@@ -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
@@ -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>)
@@ -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("&","&amp;").replace("<","&lt;").replace(">","&gt;").replace("\"","&quot;")
private fun invalid(message:String):Nothing=throw ApiException(HttpStatus.BAD_REQUEST,"PROCESS_TEMPLATE_INVALID",message)
@@ -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")))) }
}
}
+3 -1
View File
@@ -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: 已创建部门 } } }
+1
View File
@@ -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、任意表达式或任意审批人脚本。部署成功后,新定义立即出现在流程绑定工作台中。